Compare commits

..

19 Commits

Author SHA1 Message Date
dante01yoon
9893a1572c fix: add missing fields to publish workflow API request
Add models, custom_nodes, tutorial_url, and metadata fields to the
publish workflow request body to match the backend API spec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:47:36 +09:00
Dante
a3325a9ebc fix: require workflow save before entering publish wizard (#10304)
## Summary
- Adds a save gate to the ComfyHub publish wizard dialog, matching the
existing share dialog behavior
- Unsaved or modified workflows now show a save prompt before entering
the 3-step publish wizard
- Prevents confusing "Workflow not found" errors from the backend when
publishing unsaved workflows


https://github.com/user-attachments/assets/6e2b0d2b-7e25-4d65-8bf4-7d74bfc36972


## Test plan
- [x] Unit tests pass (9 existing + updated mocks)
- [x] Typecheck passes
- [x] Lint passes
- [x] Visually verified via Chrome DevTools MCP: unsaved workflow shows
save prompt instead of wizard

🤖 Generated with [Claude Code](https://claude.com/claude-code)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10304-fix-require-workflow-save-before-entering-publish-wizard-3286d73d365081bbabacfc0f18db7941)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 20:35:26 +09:00
Dante
bc811f2d73 Merge branch 'main' into drjkl/hub-integration 2026-03-19 17:29:55 +09:00
jaeone94
3591579141 fix: _removeDuplicateLinks incorrectly removes valid link when slot indices shift (#10289)
## Summary

Fixes a regression introduced in v1.41.21 where
`_removeDuplicateLinks()` (added by #9120 / backport #10045) incorrectly
removes valid links during workflow loading when the target node has
widget-to-input conversions that shift slot indices.

- Fixes https://github.com/Comfy-Org/workflow_templates/issues/715

## Root Cause

The `_removeDuplicateLinks()` method added in #9120 uses
`node.inputs[link.target_slot]` to determine which duplicate link to
keep. However, `target_slot` is the slot index recorded at serialization
time. During `LGraphNode.configure()`, the `onConnectionsChange`
callback triggers widget-to-input conversions (e.g., KSamplerAdvanced
converting `steps`, `cfg`, `start_at_step`, etc.), which inserts new
entries into the `inputs` array. This shifts indices so that
`node.inputs[target_slot]` no longer points to the expected input.

**Concrete example with `video_wan2_2_14B_i2v.json`:**

The Wan2.2 Image-to-Video subgraph contains a Switch node (id=120)
connected to KSamplerAdvanced (id=85) cfg input. The serialized data has
two links with the same connection tuple `(origin_id=120, origin_slot=0,
target_id=85, target_slot=5)`:

| Link ID | Connection | Status |
|---------|-----------|--------|
| 257 | 120:0 → 85:5 (FLOAT) | Orphaned duplicate (not referenced by any
input.link) |
| 276 | 120:0 → 85:5 (FLOAT) | Valid (referenced by node 85
input.link=276) |

When `_removeDuplicateLinks()` runs after all nodes are configured:
1. KSamplerAdvanced is created with 4 default inputs, but after
`configure()` with widget conversions, it has **13 inputs** (shifted
indices)
2. The method checks `node.inputs[5].link` (target_slot=5 from the
LLink), but index 5 is now a different input due to the shift
3. `node.inputs[5].link === null` → the method incorrectly decides link
276 is not referenced
4. **Link 276 (valid) is removed, link 257 (orphan) is kept** →
connection lost

This worked correctly in v1.41.20 because `_removeDuplicateLinks()` did
not exist.

## Changes

Replace the `target_slot`-based positional lookup with a full scan of
the target node's inputs to find which duplicate link ID is actually
referenced by `input.link`. Also repair `input.link` if it still points
to a removed duplicate after cleanup.

## Test Plan

- [x] Added regression test: shifted slot index scenario
(widget-to-input conversion)
- [x] Added regression test: `input.link` repair when pointing to
removed duplicate
- [x] Existing `_removeDuplicateLinks` tests pass (45/45)
- [x] Full unit test suite passes (6885/6885)
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes (0 errors)
- [x] Manual verification: loaded `video_wan2_2_14B_i2v.json` in clean
state — Switch→KSamplerAdvanced cfg link is now preserved
- [ ] E2E testing is difficult for this fix since it requires a workflow
with duplicate links in a subgraph containing nodes with widget-to-input
conversions (e.g., KSamplerAdvanced). The specific conditions —
duplicate LLink entries + slot index shift from widget conversion — are
hard to set up in Playwright without a pre-crafted fixture workflow and
backend node type registration. The unit tests cover the core logic
directly.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10289-fix-_removeDuplicateLinks-incorrectly-removes-valid-link-when-slot-indices-shift-3286d73d36508140b053fd538163e383)
by [Unito](https://www.unito.io)
2026-03-19 15:45:24 +09:00
Alexander Brown
f7522bcaf6 fix: blob URL leaks, Pinia reactivity, and prefill race condition
- Revoke blob URLs for images exceeding MAX_EXAMPLES in addImages/insertImagesAt

- Cap insertImagesAt to prevent growing past MAX_EXAMPLES

- Use store proxy instead of destructuring activeWorkflow (was always undefined)

- Only apply prefill to untouched form fields to avoid overwriting user edits

Amp-Thread-ID: https://ampcode.com/threads/T-019cf97c-2a79-72da-b6e8-9ed0275d1b1b
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 19:29:07 -07:00
Alexander Brown
2cf8e645db fix: add shiftKey to keyboard reorder tests in ComfyHubExamplesStep
The component requires Shift+Arrow to reorder images (plain arrows move focus). Tests were missing shiftKey: true, causing false failures.

Amp-Thread-ID: https://ampcode.com/threads/T-019cf96f-a902-76aa-a3c1-34676b56182a
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 18:48:39 -07:00
Alexander Brown
a82cead062 Merge branch 'main' into drjkl/hub-integration 2026-03-16 18:36:04 -07:00
Alexander Brown
efc4e17ad2 fix: prefill publish dialog from hub workflow metadata
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cf931-766a-73a6-9fb3-7ae89abfb134
2026-03-16 18:24:23 -07:00
Alexander Brown
1ae209d544 feat: normalize workflow tags to slug form before publishing
Tags are now lowercased with spaces replaced by hyphens before being sent to the ComfyHub API (e.g. 'Text to Image' -> 'text-to-image').

- Add normalizeTags util with deduplication

- Apply normalization in useComfyHubPublishSubmission

- Remove duplicate 'Text-to-Image' curated tag

Amp-Thread-ID: https://ampcode.com/threads/T-019cf835-b2b1-751a-a44e-a00d241dcd13
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 14:05:20 -07:00
Alexander Brown
ebd6758e24 feat: add publish finish step and profile username validation
- Extract ComfyHubFinishStep with profile card and ShareAssetWarningBox

- Show skeleton while profile loads instead of create-profile fallthrough

- Hide Additional Information section when no private assets exist

- Add username format validation to ComfyHubCreateProfileForm

Amp-Thread-ID: https://ampcode.com/threads/T-019cf7f7-be3e-701c-906f-8f9d62ea096e
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 12:52:25 -07:00
Alexander Brown
8b37638ee8 feat: improve example image tile drag-drop and keyboard interactions
- Drop files onto existing tiles to insert at that position
- Replace tiles when dropping at max capacity (8 images)
- Enforce max 8 example images limit on all add paths
- Prepend images when using the upload tile
- Arrow keys cycle focus between tiles; Shift+Arrow reorders
- Focus next sibling after deleting a tile via keyboard
- Fix thumbnail upload tile text overflow
- Prevent file drops on tiles from triggering workflow loader

Amp-Thread-ID: https://ampcode.com/threads/T-019cf7b2-f4f7-75cb-aff4-8a6471ba2f5a
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 11:01:39 -07:00
Alexander Brown
0264010b0f feat: add ReorderableExampleImage component and tests for drag-and-drop reordering
Amp-Thread-ID: https://ampcode.com/threads/T-019ceb72-8d62-74bc-847b-5dc1a3f2abbd
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 10:12:48 -07:00
Alexander Brown
96d8f77ac9 Merge remote-tracking branch 'origin/main' into drjkl/hub-integration 2026-03-16 10:00:26 -07:00
Alexander Brown
0e0568ebe3 feat: add exampleImagePosition i18n key for reorderable image tiles
Amp-Thread-ID: https://ampcode.com/threads/T-019ceb72-8d62-74bc-847b-5dc1a3f2abbd
Co-authored-by: Amp <amp@ampcode.com>
2026-03-16 10:00:09 -07:00
Alexander Brown
0e2c68e38a refactor: use defineModel for exampleImages in ComfyHubExamplesStep
Amp-Thread-ID: https://ampcode.com/threads/T-019ceb72-8d62-74bc-847b-5dc1a3f2abbd
Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 20:27:18 -07:00
Alexander Brown
1270eea2bf feat: replace example image selection with removal
Remove selectedExampleIds concept - all images present are submitted.

Add hover-reveal remove button on each example image tile.

Cap tile size at 128px with auto-fill grid layout.

Amp-Thread-ID: https://ampcode.com/threads/T-019cea06-dd47-7421-9bdc-9ef190e18e6b
Co-authored-by: Amp <amp@ampcode.com>
2026-03-13 19:03:50 -07:00
Alexander Brown
61b9712660 style: Sizing and style updates for the 2026-03-13 18:45:36 -07:00
Alexander Brown
9f546e22e4 feat: align publish dialog with Figma design
- Remove workflowType dropdown, type, and 6 unused i18n keys
- Add Cancel button to footer on all steps wired through onCancel→onClose
- Add border-t separator on footer
- Change Next button to variant="primary"
- Example images: balanced grid layout with computed column count
- Hide upload tile when max 8 images reached

Amp-Thread-ID: https://ampcode.com/threads/T-019ce932-92b1-779e-8c17-ab4e431edea8
Co-authored-by: Amp <amp@ampcode.com>
2026-03-13 18:22:24 -07:00
Alexander Brown
e8b264d2ac feat: wire comfyhub core publish and profile flows (WIP)
- Add ComfyHub service layer (upload-url, PUT upload, profile, publish)
- Migrate profile gate to new /hub/profiles endpoints + upload token flow
- Add publish submission composable (media upload + payload assembly)
- Wire publish dialog/wizard/footer with async publish, loading, error handling
- Add unit tests for service, profile gate, submission, wizard, dialog
- Add implementation plans for core wiring and figma alignment

Amp-Thread-ID: https://ampcode.com/threads/T-019ce916-b24f-70e6-880d-e57d918c7a12
Co-authored-by: Amp <amp@ampcode.com>
2026-03-13 16:28:45 -07:00
37 changed files with 2761 additions and 663 deletions

View File

@@ -51,9 +51,6 @@
# Manager
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
# Model-to-node mappings (cloud team)
/src/platform/assets/mappings/ @deepme987
# LLM Instructions (blank on purpose)
.claude/
.cursor/

View File

@@ -614,6 +614,87 @@ describe('_removeDuplicateLinks', () => {
expect(graph._links.has(dupLink.id)).toBe(false)
})
it('keeps the valid link when input.link is at a shifted slot index', () => {
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
const graph = new LGraph()
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
graph.add(source)
graph.add(target)
// Connect source:0 -> target:0, establishing input.link on target
source.connect(0, target, 0)
const validLinkId = target.inputs[0].link!
expect(graph._links.has(validLinkId)).toBe(true)
// Simulate widget-to-input conversion shifting the slot: insert a new
// input BEFORE the connected one, moving it from index 0 to index 1.
target.addInput('extra_widget', 'number')
const connectedInput = target.inputs[0]
target.inputs[0] = target.inputs[1]
target.inputs[1] = connectedInput
// Now target.inputs[1].link === validLinkId, but target.inputs[0].link is null
// Add a duplicate link with the same connection tuple (target_slot=0
// in the LLink, matching the original slot before the shift).
const dupLink = new LLink(
++graph.state.lastLinkId,
'number',
source.id,
0,
target.id,
0
)
graph._links.set(dupLink.id, dupLink)
source.outputs[0].links!.push(dupLink.id)
expect(graph._links.size).toBe(2)
graph._removeDuplicateLinks()
// The valid link (referenced by an actual input) must survive
expect(graph._links.size).toBe(1)
expect(graph._links.has(validLinkId)).toBe(true)
expect(graph._links.has(dupLink.id)).toBe(false)
expect(target.inputs[1].link).toBe(validLinkId)
})
it('repairs input.link when it points to a removed duplicate', () => {
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
const graph = new LGraph()
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
graph.add(source)
graph.add(target)
source.connect(0, target, 0)
// Create a duplicate link
const dupLink = new LLink(
++graph.state.lastLinkId,
'number',
source.id,
0,
target.id,
0
)
graph._links.set(dupLink.id, dupLink)
source.outputs[0].links!.push(dupLink.id)
// Point input.link to the duplicate (simulating corrupted state)
target.inputs[0].link = dupLink.id
graph._removeDuplicateLinks()
expect(graph._links.size).toBe(1)
// input.link must point to whichever link survived
const survivingId = graph._links.keys().next().value!
expect(target.inputs[0].link).toBe(survivingId)
expect(graph._links.has(target.inputs[0].link!)).toBe(true)
})
it('is a no-op when no duplicates exist', () => {
registerTestNodes()
const graph = new LGraph()

View File

@@ -1625,42 +1625,66 @@ export class LGraph
* output.links and the graph's _links map.
*/
_removeDuplicateLinks(): void {
const seen = new Map<string, LinkId>()
const toRemove: LinkId[] = []
// Group all link IDs by their connection tuple.
const groups = new Map<string, LinkId[]>()
for (const [id, link] of this._links) {
const key = LGraph._linkTupleKey(link)
if (seen.has(key)) {
const existingId = seen.get(key)!
// Keep the link that the input side references
const node = this.getNodeById(link.target_id)
const input = node?.inputs?.[link.target_slot]
if (input?.link === id) {
toRemove.push(existingId)
seen.set(key, id)
} else {
toRemove.push(id)
}
} else {
seen.set(key, id)
let group = groups.get(key)
if (!group) {
group = []
groups.set(key, group)
}
group.push(id)
}
for (const id of toRemove) {
const link = this._links.get(id)
if (!link) continue
for (const [, ids] of groups) {
if (ids.length <= 1) continue
// Remove from origin node's output.links array
const originNode = this.getNodeById(link.origin_id)
if (originNode) {
const output = originNode.outputs?.[link.origin_slot]
if (output?.links) {
const idx = output.links.indexOf(id)
if (idx !== -1) output.links.splice(idx, 1)
const sampleLink = this._links.get(ids[0])!
const node = this.getNodeById(sampleLink.target_id)
// Find which link ID is actually referenced by any input on the target
// node. Cannot rely on target_slot index because widget-to-input
// conversions during configure() can shift slot indices.
let keepId: LinkId | undefined
if (node) {
for (const input of node.inputs ?? []) {
const match = ids.find((id) => input.link === id)
if (match != null) {
keepId = match
break
}
}
}
keepId ??= ids[0]
this._links.delete(id)
for (const id of ids) {
if (id === keepId) continue
const link = this._links.get(id)
if (!link) continue
// Remove from origin node's output.links array
const originNode = this.getNodeById(link.origin_id)
if (originNode) {
const output = originNode.outputs?.[link.origin_slot]
if (output?.links) {
const idx = output.links.indexOf(id)
if (idx !== -1) output.links.splice(idx, 1)
}
}
this._links.delete(id)
}
// Ensure input.link points to the surviving link
if (node) {
for (const input of node.inputs ?? []) {
if (ids.includes(input.link as LinkId) && input.link !== keepId) {
input.link = keepId
}
}
}
}
}

View File

@@ -3110,6 +3110,7 @@
},
"comfyHubPublish": {
"title": "Publish to ComfyHub",
"unsavedDescription": "You must save your workflow before publishing to ComfyHub. Save it now to continue.",
"stepDescribe": "Describe your workflow",
"stepExamples": "Add output examples",
"stepFinish": "Finish publishing",
@@ -3117,12 +3118,6 @@
"workflowNamePlaceholder": "Tip: enter a descriptive name that's easy to search",
"workflowDescription": "Workflow description",
"workflowDescriptionPlaceholder": "What makes your workflow exciting and special? Be specific so people know what to expect.",
"workflowType": "Workflow type",
"workflowTypePlaceholder": "Select the type",
"workflowTypeImageGeneration": "Image generation",
"workflowTypeVideoGeneration": "Video generation",
"workflowTypeUpscaling": "Upscaling",
"workflowTypeEditing": "Editing",
"tags": "Tags",
"tagsDescription": "Select tags so people can find your workflow faster",
"tagsPlaceholder": "Enter tags that match your workflow to help people find it e.g #nanobanana, #anime or #faceswap",
@@ -3149,9 +3144,13 @@
"examplesDescription": "Add up to {total} additional sample images",
"uploadAnImage": "Click to browse or drag an image",
"uploadExampleImage": "Upload example image",
"removeExampleImage": "Remove example image",
"exampleImage": "Example image {index}",
"exampleImagePosition": "Example image {index} of {total}",
"videoPreview": "Video thumbnail preview",
"maxExamples": "You can select up to {max} examples",
"shareAs": "Share as",
"additionalInfo": "Additional information",
"createProfileToPublish": "Create a profile to publish to ComfyHub",
"createProfileCta": "Create a profile"
},
@@ -3172,6 +3171,7 @@
"namePlaceholder": "Enter your name here",
"usernameLabel": "Your username (required)",
"usernamePlaceholder": "@",
"usernameError": "342 lowercase alphanumeric characters and hyphens, must start and end with a letter or number",
"descriptionLabel": "Your description",
"descriptionPlaceholder": "Tell the community about yourself...",
"createProfile": "Create profile",

View File

@@ -1,174 +0,0 @@
/**
* Default mappings from model directories to loader nodes.
*
* Each entry maps a model folder (as it appears in the model browser)
* to the node class that loads models from that folder and the
* input key where the model name is inserted.
*
* An empty key ('') means the node auto-loads models without a widget
* selector (createModelNodeFromAsset skips widget assignment).
*
* Hierarchical fallback is handled by the store: "a/b/c" tries
* "a/b/c" → "a/b" → "a", so registering a parent directory covers
* all its children unless a more specific entry exists.
*
* Format: [modelDirectory, nodeClass, inputKey]
*/
export const MODEL_NODE_MAPPINGS: ReadonlyArray<
readonly [string, string, string]
> = [
// ---- ComfyUI core loaders ----
['checkpoints', 'CheckpointLoaderSimple', 'ckpt_name'],
['checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name'],
['loras', 'LoraLoader', 'lora_name'],
['loras', 'LoraLoaderModelOnly', 'lora_name'],
['vae', 'VAELoader', 'vae_name'],
['controlnet', 'ControlNetLoader', 'control_net_name'],
['diffusion_models', 'UNETLoader', 'unet_name'],
['upscale_models', 'UpscaleModelLoader', 'model_name'],
['style_models', 'StyleModelLoader', 'style_model_name'],
['gligen', 'GLIGENLoader', 'gligen_name'],
['clip_vision', 'CLIPVisionLoader', 'clip_name'],
['text_encoders', 'CLIPLoader', 'clip_name'],
['audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name'],
['model_patches', 'ModelPatchLoader', 'name'],
['latent_upscale_models', 'LatentUpscaleModelLoader', 'model_name'],
['clip', 'CLIPVisionLoader', 'clip_name'],
// ---- AnimateDiff (comfyui-animatediff-evolved) ----
['animatediff_models', 'ADE_LoadAnimateDiffModel', 'model_name'],
['animatediff_motion_lora', 'ADE_AnimateDiffLoRALoader', 'name'],
// ---- Chatterbox TTS (ComfyUI-Fill-Nodes) ----
['chatterbox/chatterbox', 'FL_ChatterboxTTS', ''],
['chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', ''],
['chatterbox/chatterbox_multilingual', 'FL_ChatterboxMultilingualTTS', ''],
['chatterbox/chatterbox_vc', 'FL_ChatterboxVC', ''],
// ---- SAM / SAM2 (comfyui-segment-anything-2, comfyui-impact-pack) ----
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
['sams', 'SAMLoader', 'model_name'],
// ---- SAM3 3D segmentation (comfyui-sam3) ----
['sam3', 'LoadSAM3Model', 'model_path'],
// ---- Ultralytics detection (comfyui-impact-subpack) ----
['ultralytics', 'UltralyticsDetectorProvider', 'model_name'],
// ---- DepthAnything (comfyui-depthanythingv2, comfyui-depthanythingv3) ----
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
['depthanything3', 'DownloadAndLoadDepthAnythingV3Model', 'model'],
// ---- IP-Adapter (comfyui_ipadapter_plus) ----
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
// ---- Segformer (comfyui_layerstyle) ----
['segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name'],
['segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name'],
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name'],
// ---- NLF pose estimation (ComfyUI-WanVideoWrapper) ----
['nlf', 'LoadNLFModel', 'nlf_model'],
// ---- FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast) ----
['FlashVSR', 'FlashVSRNode', ''],
['FlashVSR-v1.1', 'FlashVSRNode', ''],
// ---- SEEDVR2 video upscaling (comfyui-seedvr2) ----
['SEEDVR2', 'SeedVR2LoadDiTModel', 'model'],
// ---- Qwen VL vision-language (comfyui-qwen-vl) ----
['LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-2B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-2B-Thinking', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-4B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-4B-Thinking', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-8B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-8B-Thinking', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-32B-Instruct', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-VL-32B-Thinking', 'AILab_QwenVL', 'model_name'],
['LLM/Qwen-VL/Qwen3-0.6B', 'AILab_QwenVL_PromptEnhancer', 'model_name'],
[
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
'AILab_QwenVL_PromptEnhancer',
'model_name'
],
['LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint'],
// ---- Qwen3 TTS (ComfyUI-FunBox) ----
['qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice'],
// ---- LivePortrait (comfyui-liveportrait) ----
['liveportrait', 'DownloadAndLoadLivePortraitModels', ''],
// ---- MimicMotion (ComfyUI-MimicMotionWrapper) ----
['mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model'],
['dwpose', 'MimicMotionGetPoses', ''],
// ---- Face parsing (comfyui_face_parsing) ----
['face_parsing', 'FaceParsingModelLoader(FaceParsing)', ''],
// ---- Kolors (ComfyUI-KolorsWrapper) ----
['diffusers', 'DownloadAndLoadKolorsModel', 'model'],
// ---- RIFE video frame interpolation (ComfyUI-RIFE) ----
['rife', 'RIFE VFI', 'ckpt_name'],
// ---- UltraShape 3D model generation ----
['UltraShape', 'UltraShapeLoadModel', 'checkpoint'],
// ---- SHaRP depth estimation ----
['sharp', 'LoadSharpModel', 'checkpoint_path'],
// ---- ONNX upscale models ----
['onnx', 'UpscaleModelLoader', 'model_name'],
// ---- Detection models (vitpose, yolo) ----
['detection', 'OnnxDetectionModelLoader', 'yolo_model'],
// ---- HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper) ----
[
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
'DownloadAndLoadHyVideoTextEncoder',
'llm_model'
],
[
'LLM/llava-llama-3-8b-v1_1-transformers',
'DownloadAndLoadHyVideoTextEncoder',
'llm_model'
],
// ---- CogVideoX (comfyui-cogvideoxwrapper) ----
['CogVideo', 'DownloadAndLoadCogVideoModel', ''],
['CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model'],
['CogVideo/ControlNet', 'DownloadAndLoadCogVideoControlNet', ''],
// ---- DynamiCrafter (ComfyUI-DynamiCrafterWrapper) ----
['checkpoints/dynamicrafter', 'DownloadAndLoadDynamiCrafterModel', 'model'],
[
'checkpoints/dynamicrafter/controlnet',
'DownloadAndLoadDynamiCrafterCNModel',
'model'
],
// ---- LayerStyle (ComfyUI_LayerStyle_Advance) ----
['BEN', 'LS_LoadBenModel', 'model'],
['BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model'],
['onnx/human-parts', 'LS_HumanPartsUltra', ''],
['lama', 'LaMa', 'lama_model'],
// ---- Inpaint (comfyui-inpaint-nodes) ----
['inpaint', 'INPAINT_LoadInpaintModel', 'model_name'],
// ---- LayerDiffuse (comfyui-layerdiffuse) ----
['layer_model', 'LayeredDiffusionApply', 'config'],
// ---- LTX Video prompt enhancer (ComfyUI-LTXTricks) ----
['LLM/Llama-3.2-3B-Instruct', 'LTXVPromptEnhancerLoader', 'llm_name'],
[
'LLM/Florence-2-large-PromptGen-v2.0',
'LTXVPromptEnhancerLoader',
'image_captioner_name'
]
] as const satisfies ReadonlyArray<readonly [string, string, string]>

View File

@@ -77,6 +77,12 @@
</span>
<Input id="profile-username" v-model="username" class="pl-7" />
</div>
<p
v-if="showUsernameError"
class="text-xs text-destructive-background"
>
{{ $t('comfyHubProfile.usernameError') }}
</p>
</div>
<div class="flex flex-col gap-2">
@@ -105,7 +111,7 @@
<Button
variant="primary"
size="lg"
:disabled="!username.trim() || isCreating"
:disabled="!isUsernameValid || isCreating"
@click="handleCreate"
>
{{
@@ -156,6 +162,16 @@ const profilePictureFile = ref<File | null>(null)
const profilePreviewUrl = useObjectUrl(profilePictureFile)
const isCreating = ref(false)
const VALID_USERNAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,40}[a-z0-9]$/
const isUsernameValid = computed(() =>
VALID_USERNAME_PATTERN.test(username.value)
)
const showUsernameError = computed(
() => username.value.length > 0 && !isUsernameValid.value
)
const profileInitial = computed(() => {
const source = name.value.trim() || username.value.trim()
return source ? source[0].toUpperCase() : 'C'

View File

@@ -0,0 +1,125 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
function mountStep(
props: Partial<InstanceType<typeof ComfyHubDescribeStep>['$props']> = {}
) {
return mount(ComfyHubDescribeStep, {
props: {
name: 'Workflow Name',
description: 'Workflow description',
tags: [],
...props
},
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
Input: {
template:
'<input data-testid="name-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue']
},
Textarea: {
template:
'<textarea data-testid="description-input" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['modelValue']
},
TagsInput: {
template:
'<div data-testid="tags-input" :data-disabled="disabled ? \'true\' : \'false\'"><slot :is-empty="!modelValue || modelValue.length === 0" /></div>',
props: {
modelValue: {
type: Array,
default: () => []
},
disabled: Boolean
}
},
TagsInputItem: {
template:
'<button data-testid="tag-item" :data-value="value" type="button"><slot /></button>',
props: ['value']
},
TagsInputItemText: {
template: '<span data-testid="tag-item-text" />'
},
TagsInputItemDelete: {
template: '<button data-testid="tag-item-delete" type="button" />'
},
TagsInputInput: {
template: '<input data-testid="tags-input-input" />'
},
Button: {
template:
'<button data-testid="toggle-suggestions" type="button"><slot /></button>'
}
}
}
})
}
describe('ComfyHubDescribeStep', () => {
it('emits name and description updates', async () => {
const wrapper = mountStep()
await wrapper.find('[data-testid="name-input"]').setValue('New workflow')
await wrapper
.find('[data-testid="description-input"]')
.setValue('New description')
expect(wrapper.emitted('update:name')).toEqual([['New workflow']])
expect(wrapper.emitted('update:description')).toEqual([['New description']])
})
it('adds a suggested tag when clicked', async () => {
const wrapper = mountStep()
const suggestionButtons = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(suggestionButtons.length).toBeGreaterThan(0)
const firstSuggestion = suggestionButtons[0].attributes('data-value')
await suggestionButtons[0].trigger('click')
const tagUpdates = wrapper.emitted('update:tags')
expect(tagUpdates?.at(-1)).toEqual([[firstSuggestion]])
})
it('hides already-selected tags from suggestions', () => {
const selectedTag = COMFY_HUB_TAG_OPTIONS[0]
const wrapper = mountStep({ tags: [selectedTag] })
const suggestionValues = wrapper
.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
.map((button) => button.attributes('data-value'))
expect(suggestionValues).not.toContain(selectedTag)
})
it('toggles between default and full suggestion lists', async () => {
const wrapper = mountStep()
const defaultSuggestions = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(defaultSuggestions).toHaveLength(10)
expect(wrapper.text()).toContain('comfyHubPublish.showMoreTags')
await wrapper.find('[data-testid="toggle-suggestions"]').trigger('click')
await wrapper.vm.$nextTick()
const allSuggestions = wrapper.findAll(
'[data-testid="tags-input"][data-disabled="true"] [data-testid="tag-item"]'
)
expect(allSuggestions).toHaveLength(COMFY_HUB_TAG_OPTIONS.length)
expect(wrapper.text()).toContain('comfyHubPublish.showLessTags')
})
})

View File

@@ -25,35 +25,8 @@
<label class="flex flex-col gap-2">
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.workflowType') }}
</span>
<Select
:model-value="workflowType"
@update:model-value="
emit('update:workflowType', $event as ComfyHubWorkflowType)
"
>
<SelectTrigger>
<SelectValue
:placeholder="$t('comfyHubPublish.workflowTypePlaceholder')"
/>
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in workflowTypeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
</label>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.tagsDescription') }}
</legend>
</span>
<TagsInput
v-slot="{ isEmpty }"
always-editing
@@ -67,54 +40,48 @@
</TagsInputItem>
<TagsInputInput :is-empty />
</TagsInput>
<TagsInput
disabled
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
</label>
<TagsInput
disabled
class="hover-within:bg-transparent bg-transparent p-0 hover:bg-transparent"
>
<div
v-if="displayedSuggestions.length > 0"
class="flex basis-full flex-wrap gap-2"
>
<div
v-if="displayedSuggestions.length > 0"
class="flex basis-full flex-wrap gap-2"
<TagsInputItem
v-for="tag in displayedSuggestions"
:key="tag"
v-auto-animate
:value="tag"
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
@click="addTag(tag)"
>
<TagsInputItem
v-for="tag in displayedSuggestions"
:key="tag"
v-auto-animate
:value="tag"
class="cursor-pointer bg-secondary-background px-2 text-muted-foreground transition-colors select-none hover:bg-secondary-background-selected"
@click="addTag(tag)"
>
<TagsInputItemText />
</TagsInputItem>
</div>
<Button
v-if="shouldShowSuggestionToggle"
variant="muted-textonly"
size="unset"
class="hover:bg-unset px-0 text-xs"
@click="showAllSuggestions = !showAllSuggestions"
>
{{
$t(
showAllSuggestions
? 'comfyHubPublish.showLessTags'
: 'comfyHubPublish.showMoreTags'
)
}}
</Button>
</TagsInput>
</fieldset>
<TagsInputItemText />
</TagsInputItem>
</div>
<Button
v-if="shouldShowSuggestionToggle"
variant="muted-textonly"
size="unset"
class="hover:bg-unset px-0 text-xs"
@click="showAllSuggestions = !showAllSuggestions"
>
{{
$t(
showAllSuggestions
? 'comfyHubPublish.showLessTags'
: 'comfyHubPublish.showMoreTags'
)
}}
</Button>
</TagsInput>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
import TagsInputInput from '@/components/ui/tags-input/TagsInputInput.vue'
import TagsInputItem from '@/components/ui/tags-input/TagsInputItem.vue'
@@ -122,46 +89,21 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import Textarea from '@/components/ui/textarea/Textarea.vue'
import { COMFY_HUB_TAG_OPTIONS } from '@/platform/workflow/sharing/constants/comfyHubTags'
import type { ComfyHubWorkflowType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { vAutoAnimate } from '@formkit/auto-animate/vue'
const { tags, workflowType } = defineProps<{
const { tags } = defineProps<{
name: string
description: string
workflowType: ComfyHubWorkflowType | ''
tags: string[]
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:description': [value: string]
'update:workflowType': [value: ComfyHubWorkflowType | '']
'update:tags': [value: string[]]
}>()
const { t } = useI18n()
const workflowTypeOptions = computed(() => [
{
value: 'imageGeneration',
label: t('comfyHubPublish.workflowTypeImageGeneration')
},
{
value: 'videoGeneration',
label: t('comfyHubPublish.workflowTypeVideoGeneration')
},
{
value: 'upscaling',
label: t('comfyHubPublish.workflowTypeUpscaling')
},
{
value: 'editing',
label: t('comfyHubPublish.workflowTypeEditing')
}
])
const INITIAL_TAG_SUGGESTION_COUNT = 10
const showAllSuggestions = ref(false)

View File

@@ -0,0 +1,95 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
vi.mock('@atlaskit/pragmatic-drag-and-drop/element/adapter', () => ({
draggable: vi.fn(() => vi.fn()),
dropTargetForElements: vi.fn(() => vi.fn()),
monitorForElements: vi.fn(() => vi.fn())
}))
function createImages(count: number): ExampleImage[] {
return Array.from({ length: count }, (_, i) => ({
id: `img-${i}`,
url: `blob:http://localhost/img-${i}`
}))
}
function mountStep(images: ExampleImage[]) {
return mount(ComfyHubExamplesStep, {
props: { exampleImages: images },
global: {
mocks: { $t: (key: string) => key }
}
})
}
describe('ComfyHubExamplesStep', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders all example images', () => {
const wrapper = mountStep(createImages(3))
expect(wrapper.findAll('[role="listitem"]')).toHaveLength(3)
})
it('emits reordered array when moving image left via keyboard', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[1].trigger('keydown', { key: 'ArrowLeft', shiftKey: true })
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
const reordered = emitted![0][0] as ExampleImage[]
expect(reordered.map((img) => img.id)).toEqual(['img-1', 'img-0', 'img-2'])
})
it('emits reordered array when moving image right via keyboard', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[1].trigger('keydown', { key: 'ArrowRight', shiftKey: true })
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
const reordered = emitted![0][0] as ExampleImage[]
expect(reordered.map((img) => img.id)).toEqual(['img-0', 'img-2', 'img-1'])
})
it('does not emit when moving first image left (boundary)', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[0].trigger('keydown', { key: 'ArrowLeft' })
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
})
it('does not emit when moving last image right (boundary)', async () => {
const wrapper = mountStep(createImages(3))
const tiles = wrapper.findAll('[role="listitem"]')
await tiles[2].trigger('keydown', { key: 'ArrowRight' })
expect(wrapper.emitted('update:exampleImages')).toBeFalsy()
})
it('emits filtered array when removing an image', async () => {
const wrapper = mountStep(createImages(2))
const removeBtn = wrapper.find(
'button[aria-label="comfyHubPublish.removeExampleImage"]'
)
expect(removeBtn.exists()).toBe(true)
await removeBtn.trigger('click')
const emitted = wrapper.emitted('update:exampleImages')
expect(emitted).toBeTruthy()
expect(emitted![0][0]).toHaveLength(1)
})
})

View File

@@ -1,21 +1,25 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-6">
<p class="text-sm">
<div class="flex min-h-0 flex-1 flex-col">
<p class="text-sm select-none">
{{
$t('comfyHubPublish.examplesDescription', {
selected: selectedExampleIds.length,
total: MAX_EXAMPLES
})
}}
</p>
<div class="grid grid-cols-4 gap-2.5 overflow-y-auto">
<!-- Upload tile -->
<div
role="list"
class="group/grid grid gap-2"
style="grid-template-columns: repeat(auto-fill, 8rem)"
>
<!-- Upload tile (hidden when max images reached) -->
<label
v-if="showUploadTile"
tabindex="0"
role="button"
:aria-label="$t('comfyHubPublish.uploadExampleImage')"
class="focus-visible:outline-ring flex aspect-square h-25 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
class="focus-visible:outline-ring flex aspect-square cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-default text-center transition-colors hover:border-muted-foreground focus-visible:outline-2 focus-visible:outline-offset-2"
@dragenter.stop
@dragleave.stop
@dragover.prevent.stop
@@ -40,83 +44,100 @@
}}</span>
</label>
<!-- Example images -->
<Button
<!-- Example images (drag to reorder) -->
<ReorderableExampleImage
v-for="(image, index) in exampleImages"
:key="image.id"
variant="textonly"
size="unset"
:class="
cn(
'relative h-25 cursor-pointer overflow-hidden rounded-sm p-0',
isSelected(image.id) ? 'ring-ring ring-2' : 'ring-0'
)
"
@click="toggleSelection(image.id)"
>
<img
:src="image.url"
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
class="size-full object-cover"
/>
<div
v-if="isSelected(image.id)"
class="absolute bottom-1.5 left-1.5 flex size-7 items-center justify-center rounded-full bg-primary-background text-sm font-bold text-base-foreground"
>
{{ selectionIndex(image.id) }}
</div>
</Button>
:image="image"
:index="index"
:total="exampleImages.length"
:instance-id="instanceId"
@remove="removeImage"
@move="moveImage"
@insert-files="insertImagesAt"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import {
isFileTooLarge,
MAX_IMAGE_SIZE_MB
} from '@/platform/workflow/sharing/utils/validateFileSize'
import { cn } from '@/utils/tailwindUtil'
import ReorderableExampleImage from './ReorderableExampleImage.vue'
const fileInputRef = ref<HTMLInputElement | null>(null)
const MAX_EXAMPLES = 8
const { exampleImages, selectedExampleIds } = defineProps<{
exampleImages: ExampleImage[]
selectedExampleIds: string[]
}>()
const exampleImages = defineModel<ExampleImage[]>('exampleImages', {
required: true
})
const emit = defineEmits<{
'update:exampleImages': [value: ExampleImage[]]
'update:selectedExampleIds': [value: string[]]
}>()
const showUploadTile = computed(() => exampleImages.value.length < MAX_EXAMPLES)
function isSelected(id: string): boolean {
return selectedExampleIds.includes(id)
const instanceId = Symbol('example-images')
let cleanupMonitor = () => {}
onMounted(() => {
cleanupMonitor = monitorForElements({
canMonitor: ({ source }) => source.data.instanceId === instanceId,
onDrop: ({ source, location }) => {
const destination = location.current.dropTargets[0]
if (!destination) return
const fromId = source.data.imageId
const toId = destination.data.imageId
if (typeof fromId !== 'string' || typeof toId !== 'string') return
reorderImages(fromId, toId)
}
})
})
onBeforeUnmount(() => {
cleanupMonitor()
})
function moveByIndex(fromIndex: number, toIndex: number) {
if (fromIndex < 0 || toIndex < 0) return
if (toIndex >= exampleImages.value.length || fromIndex === toIndex) return
const updated = [...exampleImages.value]
const [moved] = updated.splice(fromIndex, 1)
updated.splice(toIndex, 0, moved)
exampleImages.value = updated
}
function selectionIndex(id: string): number {
return selectedExampleIds.indexOf(id) + 1
function reorderImages(fromId: string, toId: string) {
moveByIndex(
exampleImages.value.findIndex((img) => img.id === fromId),
exampleImages.value.findIndex((img) => img.id === toId)
)
}
function toggleSelection(id: string) {
if (isSelected(id)) {
emit(
'update:selectedExampleIds',
selectedExampleIds.filter((sid) => sid !== id)
)
} else if (selectedExampleIds.length < MAX_EXAMPLES) {
emit('update:selectedExampleIds', [...selectedExampleIds, id])
function moveImage(id: string, direction: number) {
const currentIndex = exampleImages.value.findIndex((img) => img.id === id)
moveByIndex(currentIndex, currentIndex + direction)
}
function removeImage(id: string) {
const image = exampleImages.value.find((img) => img.id === id)
if (image?.file) {
URL.revokeObjectURL(image.url)
}
exampleImages.value = exampleImages.value.filter((img) => img.id !== id)
}
function addImages(files: FileList) {
const newImages: ExampleImage[] = Array.from(files)
function createExampleImages(files: FileList): ExampleImage[] {
return Array.from(files)
.filter((f) => f.type.startsWith('image/'))
.filter((f) => !isFileTooLarge(f, MAX_IMAGE_SIZE_MB))
.map((file) => ({
@@ -124,10 +145,46 @@ function addImages(files: FileList) {
url: URL.createObjectURL(file),
file
}))
}
if (newImages.length > 0) {
emit('update:exampleImages', [...exampleImages, ...newImages])
function addImages(files: FileList) {
const remaining = MAX_EXAMPLES - exampleImages.value.length
if (remaining <= 0) return
const created = createExampleImages(files)
const newImages = created.slice(0, remaining)
for (const img of created.slice(remaining)) {
URL.revokeObjectURL(img.url)
}
if (newImages.length > 0) {
exampleImages.value = [...newImages, ...exampleImages.value]
}
}
function insertImagesAt(index: number, files: FileList) {
const created = createExampleImages(files)
if (created.length === 0) return
const updated = [...exampleImages.value]
const remaining = MAX_EXAMPLES - exampleImages.value.length
const maxInsert =
remaining <= 0 ? Math.max(updated.length - index, 0) : remaining
const newImages = created.slice(0, maxInsert)
for (const img of created.slice(maxInsert)) {
URL.revokeObjectURL(img.url)
}
if (newImages.length === 0) return
if (remaining <= 0) {
const replacedImages = updated.splice(index, newImages.length, ...newImages)
for (const img of replacedImages) {
if (img.file) URL.revokeObjectURL(img.url)
}
} else {
updated.splice(index, 0, ...newImages)
}
exampleImages.value = updated
}
function handleFileSelect(event: Event) {

View File

@@ -0,0 +1,86 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-8 px-6 py-4">
<section class="flex flex-col gap-4">
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.shareAs') }}
</span>
<div
class="flex items-center gap-4 rounded-2xl bg-secondary-background px-6 py-4"
>
<div
class="flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-full bg-linear-to-b from-green-600/50 to-green-900"
>
<img
v-if="profile.profilePictureUrl"
:src="profile.profilePictureUrl"
:alt="profile.username"
class="size-full rounded-full object-cover"
/>
<span v-else class="text-base text-white">
{{ (profile.name ?? profile.username)[0].toUpperCase() }}
</span>
</div>
<div class="flex flex-1 flex-col gap-2">
<span class="text-sm text-base-foreground">
{{ profile.name ?? profile.username }}
</span>
<span class="text-sm text-muted-foreground">
@{{ profile.username }}
</span>
</div>
</div>
</section>
<section
v-if="isLoadingAssets || hasPrivateAssets"
class="flex flex-col gap-4"
>
<span class="text-sm text-base-foreground">
{{ $t('comfyHubPublish.additionalInfo') }}
</span>
<p
v-if="isLoadingAssets"
class="m-0 text-sm text-muted-foreground italic"
>
{{ $t('shareWorkflow.checkingAssets') }}
</p>
<ShareAssetWarningBox
v-else
v-model:acknowledged="acknowledged"
:items="privateAssets"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import ShareAssetWarningBox from '@/platform/workflow/sharing/components/ShareAssetWarningBox.vue'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { computed } from 'vue'
const { profile } = defineProps<{
profile: ComfyHubProfile
}>()
const acknowledged = defineModel<boolean>('acknowledged', { default: false })
const shareService = useWorkflowShareService()
const { state: privateAssets, isLoading: isLoadingAssets } = useAsyncState(
() => shareService.getShareableAssets(),
[]
)
const hasPrivateAssets = computed(() => privateAssets.value.length > 0)
const isReady = computed(
() =>
!isLoadingAssets.value && (!hasPrivateAssets.value || acknowledged.value)
)
defineExpose({ isReady })
</script>

View File

@@ -2,6 +2,18 @@ import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useI18n: () => ({ t: (key: string) => key })
}
})
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: vi.fn() })
}))
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
const mockFetchProfile = vi.hoisted(() => vi.fn())
@@ -10,6 +22,11 @@ const mockGoNext = vi.hoisted(() => vi.fn())
const mockGoBack = vi.hoisted(() => vi.fn())
const mockOpenProfileCreationStep = vi.hoisted(() => vi.fn())
const mockCloseProfileCreationStep = vi.hoisted(() => vi.fn())
const mockApplyPrefill = vi.hoisted(() => vi.fn())
const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
@@ -28,14 +45,16 @@ vi.mock(
formData: ref({
name: '',
description: '',
workflowType: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
tutorialUrl: '',
metadata: {}
}),
isFirstStep: ref(false),
isLastStep: ref(true),
@@ -43,17 +62,64 @@ vi.mock(
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep
closeProfileCreationStep: mockCloseProfileCreationStep,
applyPrefill: mockApplyPrefill
}),
cachePublishPrefill: mockCachePublishPrefill,
getCachedPrefill: mockGetCachedPrefill
})
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishSubmission',
() => ({
useComfyHubPublishSubmission: () => ({
submitToComfyHub: mockSubmitToComfyHub
})
})
)
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getPublishStatus: mockGetPublishStatus
})
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
renameWorkflow: vi.fn(),
saveWorkflow: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: {
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: false
},
saveWorkflow: vi.fn()
})
}))
describe('ComfyHubPublishDialog', () => {
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockFetchProfile.mockResolvedValue(null)
mockSubmitToComfyHub.mockResolvedValue(undefined)
mockGetCachedPrefill.mockReturnValue(null)
mockGetPublishStatus.mockResolvedValue({
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null,
prefill: null
})
})
function createWrapper() {
@@ -78,14 +144,17 @@ describe('ComfyHubPublishDialog', () => {
},
ComfyHubPublishWizardContent: {
template:
'<div><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /></div>',
'<div :data-is-publishing="$props.isPublishing"><button data-testid="require-profile" @click="$props.onRequireProfile()" /><button data-testid="gate-complete" @click="$props.onGateComplete()" /><button data-testid="gate-close" @click="$props.onGateClose()" /><button data-testid="publish" @click="$props.onPublish()" /><button data-testid="cancel" @click="$props.onCancel()" /></div>',
props: [
'currentStep',
'formData',
'isFirstStep',
'isLastStep',
'isPublishing',
'onGoNext',
'onGoBack',
'onCancel',
'onPublish',
'onRequireProfile',
'onGateComplete',
'onGateClose'
@@ -136,4 +205,72 @@ describe('ComfyHubPublishDialog', () => {
expect(mockCloseProfileCreationStep).toHaveBeenCalledOnce()
expect(onClose).not.toHaveBeenCalled()
})
it('closes dialog after successful publish', async () => {
const wrapper = createWrapper()
await flushPromises()
await wrapper.find('[data-testid="publish"]').trigger('click')
await flushPromises()
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('applies prefill when workflow is already published with metadata', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc123',
shareUrl: 'http://localhost/?share=abc123',
publishedAt: new Date(),
prefill: {
description: 'Existing description',
tags: ['art', 'upscale'],
thumbnailType: 'video',
sampleImageUrls: ['https://example.com/img1.png']
}
})
createWrapper()
await flushPromises()
expect(mockApplyPrefill).toHaveBeenCalledWith({
description: 'Existing description',
tags: ['art', 'upscale'],
thumbnailType: 'video',
sampleImageUrls: ['https://example.com/img1.png']
})
})
it('does not apply prefill when workflow is not published', async () => {
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
})
it('does not apply prefill when status has no prefill data', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
shareId: 'abc123',
shareUrl: 'http://localhost/?share=abc123',
publishedAt: new Date(),
prefill: null
})
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
})
it('silently ignores prefill fetch errors', async () => {
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
createWrapper()
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
})

View File

@@ -12,44 +12,107 @@
</template>
<template #leftPanel>
<ComfyHubPublishNav :current-step @step-click="goToStep" />
<ComfyHubPublishNav
v-if="!needsSave"
:current-step
@step-click="goToStep"
/>
</template>
<template #header />
<template #content>
<div v-if="needsSave" class="flex flex-col gap-4 p-6">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('comfyHubPublish.unsavedDescription') }}
</p>
<label v-if="isTemporary" class="flex flex-col gap-1">
<span class="text-sm font-medium text-muted-foreground">
{{ $t('shareWorkflow.workflowNameLabel') }}
</span>
<Input
ref="nameInputRef"
v-model="workflowName"
:disabled="isSaving"
@keydown.enter="() => handleSave()"
/>
</label>
<Button
variant="primary"
size="lg"
:loading="isSaving"
@click="() => handleSave()"
>
{{
isSaving
? $t('shareWorkflow.saving')
: $t('shareWorkflow.saveButton')
}}
</Button>
</div>
<ComfyHubPublishWizardContent
v-else
:current-step
:form-data
:is-first-step
:is-last-step
:is-publishing
:on-update-form-data="updateFormData"
:on-go-next="goNext"
:on-go-back="goBack"
:on-cancel="onClose"
:on-require-profile="handleRequireProfile"
:on-gate-complete="handlePublishGateComplete"
:on-gate-close="handlePublishGateClose"
:on-publish="onClose"
:on-publish="handlePublish"
/>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, provide } from 'vue'
import { useAsyncState } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
provide,
ref,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import ComfyHubPublishNav from '@/platform/workflow/sharing/components/publish/ComfyHubPublishNav.vue'
import ComfyHubPublishWizardContent from '@/platform/workflow/sharing/components/publish/ComfyHubPublishWizardContent.vue'
import { useComfyHubPublishWizard } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { useComfyHubPublishSubmission } from '@/platform/workflow/sharing/composables/useComfyHubPublishSubmission'
import {
cachePublishPrefill,
getCachedPrefill,
useComfyHubPublishWizard
} from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { appendJsonExt } from '@/utils/formatUtil'
import { OnCloseKey } from '@/types/widgetTypes'
const { onClose } = defineProps<{
onClose: () => void
}>()
const { t } = useI18n()
const toast = useToast()
const { fetchProfile } = useComfyHubProfileGate()
const { submitToComfyHub } = useComfyHubPublishSubmission()
const shareService = useWorkflowShareService()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const {
currentStep,
formData,
@@ -59,8 +122,72 @@ const {
goNext,
goBack,
openProfileCreationStep,
closeProfileCreationStep
closeProfileCreationStep,
applyPrefill
} = useComfyHubPublishWizard()
const isPublishing = ref(false)
const needsSave = ref(false)
const workflowName = ref('')
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
const isTemporary = computed(
() => workflowStore.activeWorkflow?.isTemporary ?? false
)
function checkNeedsSave() {
const workflow = workflowStore.activeWorkflow
needsSave.value = !workflow || workflow.isTemporary || workflow.isModified
if (workflow) {
workflowName.value = workflow.filename.replace(/\.json$/i, '')
}
}
watch(needsSave, async (needs) => {
if (needs && isTemporary.value) {
await nextTick()
nameInputRef.value?.focus()
nameInputRef.value?.select()
}
})
function buildWorkflowPath(directory: string, filename: string): string {
const normalizedDirectory = directory.replace(/\/+$/, '')
const normalizedFilename = appendJsonExt(filename.replace(/\.json$/i, ''))
return normalizedDirectory
? `${normalizedDirectory}/${normalizedFilename}`
: normalizedFilename
}
const { isLoading: isSaving, execute: handleSave } = useAsyncState(
async () => {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
if (workflow.isTemporary) {
const name = workflowName.value.trim()
if (!name) return
const newPath = buildWorkflowPath(workflow.directory, name)
await workflowService.renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
await workflowService.saveWorkflow(workflow)
}
checkNeedsSave()
},
undefined,
{
immediate: false,
onError: (error) => {
console.error('Failed to save workflow:', error)
toast.add({
severity: 'error',
summary: t('shareWorkflow.saveFailedTitle'),
detail: t('shareWorkflow.saveFailedDescription')
})
}
}
)
function handlePublishGateComplete() {
closeProfileCreationStep()
@@ -75,18 +202,59 @@ function handleRequireProfile() {
openProfileCreationStep()
}
async function handlePublish(): Promise<void> {
if (isPublishing.value) {
return
}
isPublishing.value = true
try {
await submitToComfyHub(formData.value)
const path = workflowStore.activeWorkflow?.path
if (path) {
cachePublishPrefill(path, formData.value)
}
onClose()
} finally {
isPublishing.value = false
}
}
function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
formData.value = { ...formData.value, ...patch }
}
async function fetchPublishPrefill() {
const path = workflowStore.activeWorkflow?.path
if (!path) return
try {
const status = await shareService.getPublishStatus(path)
const prefill = status.isPublished
? (status.prefill ?? getCachedPrefill(path))
: getCachedPrefill(path)
if (prefill) {
applyPrefill(prefill)
}
} catch {
const cached = getCachedPrefill(path)
if (cached) {
applyPrefill(cached)
}
}
}
onMounted(() => {
// Prefetch profile data in the background so finish-step profile context is ready.
checkNeedsSave()
void fetchProfile()
void fetchPublishPrefill()
})
onBeforeUnmount(() => {
for (const image of formData.value.exampleImages) {
URL.revokeObjectURL(image.url)
if (image.file) {
URL.revokeObjectURL(image.url)
}
}
})

View File

@@ -1,26 +1,30 @@
<template>
<footer class="flex shrink items-center justify-between py-2">
<div>
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
{{ $t('comfyHubPublish.back') }}
</Button>
</div>
<div class="flex gap-4">
<Button v-if="!isLastStep" size="lg" @click="$emit('next')">
{{ $t('comfyHubPublish.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
<Button
v-else
variant="primary"
size="lg"
:disabled="isPublishDisabled"
@click="$emit('publish')"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('comfyHubPublish.publishButton') }}
</Button>
</div>
<footer
class="flex shrink items-center justify-end gap-4 border-t border-border-default px-6 py-4"
>
<Button v-if="!isFirstStep" size="lg" @click="$emit('back')">
{{ $t('comfyHubPublish.back') }}
</Button>
<Button
v-if="!isLastStep"
variant="primary"
size="lg"
@click="$emit('next')"
>
{{ $t('comfyHubPublish.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
<Button
v-else
variant="primary"
size="lg"
:disabled="isPublishDisabled || isPublishing"
:loading="isPublishing"
@click="$emit('publish')"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('comfyHubPublish.publishButton') }}
</Button>
</footer>
</template>
@@ -31,6 +35,7 @@ defineProps<{
isFirstStep: boolean
isLastStep: boolean
isPublishDisabled?: boolean
isPublishing?: boolean
}>()
defineEmits<{

View File

@@ -1,6 +1,6 @@
<template>
<nav class="flex flex-col gap-6 px-3 py-4">
<ol class="flex flex-col">
<ol class="flex list-none flex-col p-0">
<li
v-for="step in steps"
:key="step.name"

View File

@@ -8,13 +8,20 @@ import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/
const mockCheckProfile = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockHasProfile = ref<boolean | null>(true)
const mockIsFetchingProfile = ref(false)
const mockProfile = ref<{ username: string; name?: string } | null>({
username: 'testuser',
name: 'Test User'
})
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
checkProfile: mockCheckProfile,
hasProfile: mockHasProfile
hasProfile: mockHasProfile,
isFetchingProfile: mockIsFetchingProfile,
profile: mockProfile
})
})
)
@@ -39,14 +46,16 @@ function createDefaultFormData(): ComfyHubPublishFormData {
return {
name: 'Test Workflow',
description: '',
workflowType: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
tutorialUrl: '',
metadata: {}
}
}
@@ -54,6 +63,7 @@ describe('ComfyHubPublishWizardContent', () => {
const onPublish = vi.fn()
const onGoNext = vi.fn()
const onGoBack = vi.fn()
const onCancel = vi.fn()
const onUpdateFormData = vi.fn()
const onRequireProfile = vi.fn()
const onGateComplete = vi.fn()
@@ -61,8 +71,11 @@ describe('ComfyHubPublishWizardContent', () => {
beforeEach(() => {
vi.clearAllMocks()
onPublish.mockResolvedValue(undefined)
mockCheckProfile.mockResolvedValue(true)
mockHasProfile.value = true
mockIsFetchingProfile.value = false
mockProfile.value = { username: 'testuser', name: 'Test User' }
mockFlags.comfyHubProfileGateEnabled = true
})
@@ -79,6 +92,7 @@ describe('ComfyHubPublishWizardContent', () => {
isLastStep: true,
onGoNext,
onGoBack,
onCancel,
onUpdateFormData,
onPublish,
onRequireProfile,
@@ -99,9 +113,19 @@ describe('ComfyHubPublishWizardContent', () => {
template: '<div data-testid="publish-gate-flow" />',
props: ['onProfileCreated', 'onClose', 'showCloseButton']
},
Skeleton: {
template: '<div class="skeleton" />'
},
ComfyHubDescribeStep: {
template: '<div data-testid="describe-step" />'
},
ComfyHubFinishStep: {
template: '<div data-testid="finish-step" />',
props: ['profile', 'acknowledged'],
setup() {
return { isReady: true }
}
},
ComfyHubExamplesStep: {
template: '<div data-testid="examples-step" />'
},
@@ -115,52 +139,33 @@ describe('ComfyHubPublishWizardContent', () => {
},
ComfyHubPublishFooter: {
template:
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /></div>',
props: ['isFirstStep', 'isLastStep', 'isPublishDisabled'],
emits: ['publish', 'next', 'back']
'<div data-testid="publish-footer" :data-publish-disabled="isPublishDisabled" :data-is-publishing="isPublishing"><button data-testid="publish-btn" @click="$emit(\'publish\')" /><button data-testid="next-btn" @click="$emit(\'next\')" /><button data-testid="back-btn" @click="$emit(\'back\')" /><button data-testid="cancel-btn" @click="$emit(\'cancel\')" /></div>',
props: [
'isFirstStep',
'isLastStep',
'isPublishDisabled',
'isPublishing'
],
emits: ['publish', 'next', 'back', 'cancel']
}
}
}
})
}
describe('handlePublish — double-click guard', () => {
it('prevents concurrent publish calls', async () => {
let resolveCheck!: (v: boolean) => void
mockCheckProfile.mockReturnValue(
new Promise<boolean>((resolve) => {
resolveCheck = resolve
})
)
function createDeferred<T>() {
let resolve: (value: T) => void = () => {}
let reject: (error: unknown) => void = () => {}
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await publishBtn.trigger('click')
resolveCheck(true)
await flushPromises()
expect(mockCheckProfile).toHaveBeenCalledTimes(1)
expect(onPublish).toHaveBeenCalledTimes(1)
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
})
describe('handlePublish — feature flag bypass', () => {
it('calls onPublish directly when profile gate is disabled', async () => {
mockFlags.comfyHubProfileGateEnabled = false
return { promise, resolve, reject }
}
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).not.toHaveBeenCalled()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('handlePublish — profile check routing', () => {
describe('handlePublish - profile check routing', () => {
it('calls onPublish when profile exists', async () => {
mockCheckProfile.mockResolvedValue(true)
@@ -197,20 +202,83 @@ describe('ComfyHubPublishWizardContent', () => {
expect(onRequireProfile).not.toHaveBeenCalled()
})
it('resets guard after checkProfile error so retry is possible', async () => {
mockCheckProfile.mockRejectedValueOnce(new Error('Network error'))
it('calls onPublish directly when profile gate is disabled', async () => {
mockFlags.comfyHubProfileGateEnabled = false
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(mockCheckProfile).not.toHaveBeenCalled()
expect(onPublish).toHaveBeenCalledOnce()
})
})
describe('handlePublish - async submission', () => {
it('prevents duplicate publish submissions while in-flight', async () => {
const publishDeferred = createDeferred<void>()
onPublish.mockReturnValue(publishDeferred.promise)
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledTimes(1)
publishDeferred.resolve(undefined)
await flushPromises()
})
it('calls onPublish and does not close when publish request fails', async () => {
const publishError = new Error('Publish failed')
onPublish.mockRejectedValueOnce(publishError)
const wrapper = createWrapper()
await wrapper.find('[data-testid="publish-btn"]').trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
expect(mockToastErrorHandler).toHaveBeenCalledWith(publishError)
expect(onGateClose).not.toHaveBeenCalled()
})
it('shows publish disabled while submitting', async () => {
const publishDeferred = createDeferred<void>()
onPublish.mockReturnValue(publishDeferred.promise)
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).not.toHaveBeenCalled()
mockCheckProfile.mockResolvedValue(true)
const footer = wrapper.find('[data-testid="publish-footer"]')
expect(footer.attributes('data-publish-disabled')).toBe('true')
expect(footer.attributes('data-is-publishing')).toBe('true')
publishDeferred.resolve(undefined)
await flushPromises()
expect(footer.attributes('data-is-publishing')).toBe('false')
})
it('resets guard after publish error so retry is possible', async () => {
onPublish.mockRejectedValueOnce(new Error('Publish failed'))
const wrapper = createWrapper()
const publishBtn = wrapper.find('[data-testid="publish-btn"]')
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledOnce()
onPublish.mockResolvedValueOnce(undefined)
await publishBtn.trigger('click')
await flushPromises()
expect(onPublish).toHaveBeenCalledTimes(2)
})
})

View File

@@ -7,17 +7,15 @@
:on-close="onGateClose"
:show-close-button="false"
/>
<div v-else class="flex min-h-0 flex-1 flex-col px-6 pt-4 pb-2">
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<ComfyHubDescribeStep
v-if="currentStep === 'describe'"
:name="formData.name"
:description="formData.description"
:workflow-type="formData.workflowType"
:tags="formData.tags"
@update:name="onUpdateFormData({ name: $event })"
@update:description="onUpdateFormData({ description: $event })"
@update:workflow-type="onUpdateFormData({ workflowType: $event })"
@update:tags="onUpdateFormData({ tags: $event })"
/>
<div
@@ -37,13 +35,22 @@
/>
<ComfyHubExamplesStep
:example-images="formData.exampleImages"
:selected-example-ids="formData.selectedExampleIds"
@update:example-images="onUpdateFormData({ exampleImages: $event })"
@update:selected-example-ids="
onUpdateFormData({ selectedExampleIds: $event })
"
/>
</div>
<div
v-else-if="currentStep === 'finish' && isProfileLoading"
class="flex min-h-0 flex-1 flex-col gap-4 px-6 py-4"
>
<Skeleton class="h-4 w-1/4" />
<Skeleton class="h-20 w-full rounded-2xl" />
</div>
<ComfyHubFinishStep
v-else-if="currentStep === 'finish' && hasProfile && profile"
ref="finishStepRef"
v-model:acknowledged="assetsAcknowledged"
:profile
/>
<ComfyHubProfilePromptPanel
v-else-if="currentStep === 'finish'"
@request-profile="onRequireProfile"
@@ -53,7 +60,9 @@
:is-first-step
:is-last-step
:is-publish-disabled
:is-publishing="isPublishInFlight"
@back="onGoBack"
@cancel="onCancel"
@next="onGoNext"
@publish="handlePublish"
/>
@@ -70,8 +79,10 @@ import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/
import ComfyHubCreateProfileForm from '@/platform/workflow/sharing/components/profile/ComfyHubCreateProfileForm.vue'
import type { ComfyHubPublishStep } from '@/platform/workflow/sharing/composables/useComfyHubPublishWizard'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ComfyHubDescribeStep from './ComfyHubDescribeStep.vue'
import ComfyHubExamplesStep from './ComfyHubExamplesStep.vue'
import ComfyHubFinishStep from './ComfyHubFinishStep.vue'
import ComfyHubProfilePromptPanel from './ComfyHubProfilePromptPanel.vue'
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
@@ -81,8 +92,10 @@ const {
formData,
isFirstStep,
isLastStep,
isPublishing = false,
onGoNext,
onGoBack,
onCancel,
onUpdateFormData,
onPublish,
onRequireProfile,
@@ -93,10 +106,12 @@ const {
formData: ComfyHubPublishFormData
isFirstStep: boolean
isLastStep: boolean
isPublishing?: boolean
onGoNext: () => void
onGoBack: () => void
onCancel: () => void
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void
onPublish: () => void
onPublish: () => Promise<void>
onRequireProfile: () => void
onGateComplete?: () => void
onGateClose?: () => void
@@ -104,24 +119,36 @@ const {
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { checkProfile, hasProfile } = useComfyHubProfileGate()
const { checkProfile, hasProfile, isFetchingProfile, profile } =
useComfyHubProfileGate()
const isProfileLoading = computed(
() => hasProfile.value === null || isFetchingProfile.value
)
const finishStepRef = ref<InstanceType<typeof ComfyHubFinishStep> | null>(null)
const assetsAcknowledged = ref(false)
const isResolvingPublishAccess = ref(false)
const isPublishInFlight = computed(
() => isPublishing || isResolvingPublishAccess.value
)
const isPublishDisabled = computed(
() => flags.comfyHubProfileGateEnabled && hasProfile.value !== true
() =>
isPublishInFlight.value ||
(flags.comfyHubProfileGateEnabled && hasProfile.value !== true) ||
(finishStepRef.value !== null && !finishStepRef.value.isReady)
)
async function handlePublish() {
if (isResolvingPublishAccess.value) {
return
}
if (!flags.comfyHubProfileGateEnabled) {
onPublish()
if (isResolvingPublishAccess.value || isPublishing) {
return
}
isResolvingPublishAccess.value = true
try {
if (!flags.comfyHubProfileGateEnabled) {
await onPublish()
return
}
let profileExists: boolean
try {
profileExists = await checkProfile()
@@ -131,11 +158,13 @@ async function handlePublish() {
}
if (profileExists) {
onPublish()
await onPublish()
return
}
onRequireProfile()
} catch (error) {
toastErrorHandler(error)
} finally {
isResolvingPublishAccess.value = false
}

View File

@@ -1,9 +1,9 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-6">
<fieldset class="flex flex-col gap-2">
<legend class="text-sm text-base-foreground">
<div class="flex flex-col gap-2">
<span class="text-sm text-base-foreground select-none">
{{ $t('comfyHubPublish.selectAThumbnail') }}
</legend>
</span>
<ToggleGroup
type="single"
:model-value="thumbnailType"
@@ -14,18 +14,19 @@
v-for="option in thumbnailOptions"
:key="option.value"
:value="option.value"
class="h-auto w-full rounded-sm bg-node-component-surface p-2 data-[state=on]:bg-muted-background"
class="flex h-auto w-full gap-2 rounded-sm bg-node-component-surface p-2 font-inter text-base-foreground data-[state=on]:bg-muted-background"
>
<span class="text-center text-sm font-bold text-base-foreground">
<i :class="cn('size-4', option.icon)" />
<span class="text-center text-sm font-bold">
{{ option.label }}
</span>
</ToggleGroupItem>
</ToggleGroup>
</fieldset>
</div>
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-sm text-base-foreground">
<span class="text-sm text-base-foreground select-none">
{{ uploadSectionLabel }}
</span>
<Button
@@ -40,7 +41,7 @@
<template v-if="thumbnailType === 'imageComparison'">
<div
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe"
class="grid flex-1 grid-cols-1 grid-rows-1 place-content-center-safe overflow-hidden"
>
<div
v-if="hasBothComparisonImages"
@@ -69,7 +70,7 @@
<div
:class="
cn(
'col-span-full row-span-full flex gap-2',
'col-span-full row-span-full flex items-center-safe justify-center-safe gap-2',
hasBothComparisonImages && 'invisible'
)
"
@@ -80,8 +81,10 @@
:ref="(el) => (comparisonDropRefs[slot.key] = el as HTMLElement)"
:class="
cn(
'flex max-w-1/2 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
comparisonPreviewUrls[slot.key] ? 'self-start' : 'flex-1',
'flex aspect-square h-full min-h-0 flex-[0_1_auto] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
comparisonPreviewUrls[slot.key]
? 'self-start'
: 'flex-[0_1_1]',
comparisonOverStates[slot.key]
? 'border-muted-foreground'
: 'border-border-default hover:border-muted-foreground'
@@ -123,7 +126,7 @@
ref="singleDropRef"
:class="
cn(
'flex cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition-colors',
'm-auto flex aspect-square min-h-0 w-full max-w-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed text-center transition-colors',
thumbnailPreviewUrl ? 'self-center p-1' : 'flex-1',
isOverSingleDrop
? 'border-muted-foreground'
@@ -239,15 +242,18 @@ const uploadDropText = computed(() =>
const thumbnailOptions = [
{
value: 'image' as const,
label: t('comfyHubPublish.thumbnailImage')
label: t('comfyHubPublish.thumbnailImage'),
icon: 'icon-[lucide--image]'
},
{
value: 'video' as const,
label: t('comfyHubPublish.thumbnailVideo')
label: t('comfyHubPublish.thumbnailVideo'),
icon: 'icon-[lucide--video]'
},
{
value: 'imageComparison' as const,
label: t('comfyHubPublish.thumbnailImageComparison')
label: t('comfyHubPublish.thumbnailImageComparison'),
icon: 'icon-[lucide--diff]'
}
]

View File

@@ -0,0 +1,159 @@
<template>
<div
ref="tileRef"
:class="
cn(
'group focus-visible:outline-ring relative aspect-square overflow-hidden rounded-sm outline-offset-2 focus-visible:outline-2',
state === 'dragging' && 'opacity-40',
state === 'over' && 'ring-2 ring-primary'
)
"
tabindex="0"
role="listitem"
:aria-label="
$t('comfyHubPublish.exampleImagePosition', {
index: index + 1,
total: total
})
"
@pointerdown="tileRef && focusVisible(tileRef)"
@keydown.left.prevent="handleArrowKey(-1, $event)"
@keydown.right.prevent="handleArrowKey(1, $event)"
@keydown.delete.prevent="handleRemove"
@keydown.backspace.prevent="handleRemove"
@dragover.prevent.stop
@drop.prevent.stop="handleFileDrop"
>
<img
:src="image.url"
:alt="$t('comfyHubPublish.exampleImage', { index: index + 1 })"
class="pointer-events-none size-full object-cover"
draggable="false"
/>
<Button
variant="textonly"
size="icon"
:aria-label="$t('comfyHubPublish.removeExampleImage')"
tabindex="-1"
class="absolute top-1 right-1 flex size-6 items-center justify-center bg-black/60 text-white opacity-0 transition-opacity not-group-has-focus-visible/grid:group-hover:opacity-100 group-focus-visible:opacity-100 hover:bg-black/80"
@click="$emit('remove', image.id)"
>
<i class="icon-[lucide--x] size-4" aria-hidden="true" />
</Button>
</div>
</template>
<script setup lang="ts">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import { nextTick, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import type { ExampleImage } from '@/platform/workflow/sharing/types/comfyHubTypes'
import { cn } from '@/utils/tailwindUtil'
const { image, index, total, instanceId } = defineProps<{
image: ExampleImage
index: number
total: number
instanceId: symbol
}>()
const emit = defineEmits<{
remove: [id: string]
move: [id: string, direction: number]
insertFiles: [index: number, files: FileList]
}>()
function focusVisible(el: HTMLElement) {
el.focus({ focusVisible: true } as FocusOptions)
}
async function handleArrowKey(direction: number, event: KeyboardEvent) {
if (event.shiftKey) {
emit('move', image.id, direction)
await nextTick()
if (tileRef.value) focusVisible(tileRef.value)
} else {
focusSibling(direction)
}
}
function focusSibling(direction: number) {
const sibling =
direction < 0
? tileRef.value?.previousElementSibling
: tileRef.value?.nextElementSibling
if (sibling instanceof HTMLElement) focusVisible(sibling)
}
async function handleRemove() {
const next =
tileRef.value?.nextElementSibling ?? tileRef.value?.previousElementSibling
emit('remove', image.id)
await nextTick()
if (next instanceof HTMLElement) focusVisible(next)
}
function handleFileDrop(event: DragEvent) {
if (event.dataTransfer?.files?.length) {
emit('insertFiles', index, event.dataTransfer.files)
}
}
const tileRef = ref<HTMLElement | null>(null)
type DragState = 'idle' | 'dragging' | 'over'
const state = ref<DragState>('idle')
const tileGetter = () => tileRef.value as HTMLElement
usePragmaticDraggable(tileGetter, {
getInitialData: () => ({
type: 'example-image',
imageId: image.id,
instanceId
}),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
nativeSetDragImage,
render: ({ container }) => {
const img = tileRef.value?.querySelector('img')
if (!img) return
const preview = img.cloneNode(true) as HTMLImageElement
preview.style.width = '8rem'
preview.style.height = '8rem'
preview.style.objectFit = 'cover'
preview.style.borderRadius = '4px'
container.appendChild(preview)
}
})
},
onDragStart: () => {
state.value = 'dragging'
},
onDrop: () => {
state.value = 'idle'
}
})
usePragmaticDroppable(tileGetter, {
getData: () => ({ imageId: image.id }),
canDrop: ({ source }) =>
source.data.instanceId === instanceId &&
source.data.type === 'example-image' &&
source.data.imageId !== image.id,
onDragEnter: () => {
state.value = 'over'
},
onDragLeave: () => {
state.value = 'idle'
},
onDrop: () => {
state.value = 'idle'
}
})
</script>

View File

@@ -2,16 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockGetMyProfile = vi.hoisted(() => vi.fn())
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
const mockCreateProfile = vi.hoisted(() => vi.fn())
const mockToastErrorHandler = vi.hoisted(() => vi.fn())
const mockResolvedUserInfo = vi.hoisted(() => ({
value: { id: 'user-a' }
}))
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: mockFetchApi
}
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
useComfyHubService: () => ({
getMyProfile: mockGetMyProfile,
requestAssetUploadUrl: mockRequestAssetUploadUrl,
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
createProfile: mockCreateProfile
})
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
@@ -35,19 +41,16 @@ const mockProfile: ComfyHubProfile = {
description: 'A test profile'
}
function mockSuccessResponse(data?: unknown) {
return {
ok: true,
json: async () => data ?? mockProfile
} as Response
}
function mockErrorResponse(status = 500, message = 'Server error') {
return {
ok: false,
status,
json: async () => ({ message })
} as Response
function setCurrentWorkspace(workspaceId: string) {
sessionStorage.setItem(
'Comfy.Workspace.Current',
JSON.stringify({
id: workspaceId,
type: 'team',
name: 'Test Workspace',
role: 'owner'
})
)
}
describe('useComfyHubProfileGate', () => {
@@ -56,6 +59,15 @@ describe('useComfyHubProfileGate', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolvedUserInfo.value = { id: 'user-a' }
setCurrentWorkspace('workspace-1')
mockGetMyProfile.mockResolvedValue(mockProfile)
mockRequestAssetUploadUrl.mockResolvedValue({
uploadUrl: 'https://upload.example.com/avatar.png',
publicUrl: 'https://cdn.example.com/avatar.png',
token: 'avatar-token'
})
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
mockCreateProfile.mockResolvedValue(mockProfile)
// Reset module-level singleton refs
gate = useComfyHubProfileGate()
@@ -66,50 +78,30 @@ describe('useComfyHubProfileGate', () => {
})
describe('fetchProfile', () => {
it('returns mapped profile when API responds ok', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
it('fetches profile from /hub/profiles/me', async () => {
const profile = await gate.fetchProfile()
expect(profile).toEqual(mockProfile)
expect(gate.hasProfile.value).toBe(true)
expect(gate.profile.value).toEqual(mockProfile)
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profile')
expect(mockGetMyProfile).toHaveBeenCalledOnce()
})
it('returns cached profile when already fetched', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
it('reuses cached profile state per user', async () => {
await gate.fetchProfile()
const profile = await gate.fetchProfile()
expect(profile).toEqual(mockProfile)
expect(mockFetchApi).toHaveBeenCalledTimes(1)
})
it('re-fetches profile when force option is enabled', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.fetchProfile()
await gate.fetchProfile({ force: true })
expect(mockGetMyProfile).toHaveBeenCalledTimes(1)
expect(mockFetchApi).toHaveBeenCalledTimes(2)
})
mockResolvedUserInfo.value = { id: 'user-b' }
await gate.fetchProfile()
it('returns null when API responds with error', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
const profile = await gate.fetchProfile()
expect(profile).toBeNull()
expect(gate.hasProfile.value).toBe(false)
expect(gate.profile.value).toBeNull()
expect(mockGetMyProfile).toHaveBeenCalledTimes(2)
})
it('sets isFetchingProfile during fetch', async () => {
let resolvePromise: (v: Response) => void
mockFetchApi.mockReturnValue(
new Promise<Response>((resolve) => {
let resolvePromise: (v: ComfyHubProfile | null) => void
mockGetMyProfile.mockReturnValue(
new Promise<ComfyHubProfile | null>((resolve) => {
resolvePromise = resolve
})
)
@@ -117,7 +109,7 @@ describe('useComfyHubProfileGate', () => {
const promise = gate.fetchProfile()
expect(gate.isFetchingProfile.value).toBe(true)
resolvePromise!(mockSuccessResponse())
resolvePromise!(mockProfile)
await promise
expect(gate.isFetchingProfile.value).toBe(false)
@@ -126,7 +118,7 @@ describe('useComfyHubProfileGate', () => {
describe('checkProfile', () => {
it('returns true when API responds ok', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
mockGetMyProfile.mockResolvedValue(mockProfile)
const result = await gate.checkProfile()
@@ -134,105 +126,62 @@ describe('useComfyHubProfileGate', () => {
expect(gate.hasProfile.value).toBe(true)
})
it('returns false when API responds with error', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(404))
it('returns false when no profile exists', async () => {
mockGetMyProfile.mockResolvedValue(null)
const result = await gate.checkProfile()
expect(result).toBe(false)
expect(gate.hasProfile.value).toBe(false)
})
it('returns cached value without re-fetching', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.checkProfile()
const result = await gate.checkProfile()
expect(result).toBe(true)
expect(mockFetchApi).toHaveBeenCalledTimes(1)
})
it('clears cached profile state when the authenticated user changes', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.checkProfile()
mockResolvedUserInfo.value = { id: 'user-b' }
await gate.checkProfile()
expect(mockFetchApi).toHaveBeenCalledTimes(2)
})
})
describe('createProfile', () => {
it('sends FormData with required username', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.createProfile({ username: 'testuser' })
const [url, options] = mockFetchApi.mock.calls[0]
expect(url).toBe('/hub/profile')
expect(options.method).toBe('POST')
const body = options.body as FormData
expect(body.get('username')).toBe('testuser')
})
it('includes optional fields when provided', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
const coverImage = new File(['img'], 'cover.png')
it('creates profile with workspace_id and avatar token', async () => {
const profilePicture = new File(['img'], 'avatar.png')
await gate.createProfile({
username: 'testuser',
name: 'Test User',
description: 'Hello',
coverImage,
profilePicture
})
const body = mockFetchApi.mock.calls[0][1].body as FormData
expect(body.get('name')).toBe('Test User')
expect(body.get('description')).toBe('Hello')
expect(body.get('cover_image')).toBe(coverImage)
expect(body.get('profile_picture')).toBe(profilePicture)
})
it('sets profile state on success', async () => {
mockFetchApi.mockResolvedValue(mockSuccessResponse())
await gate.createProfile({ username: 'testuser' })
expect(gate.hasProfile.value).toBe(true)
expect(gate.profile.value).toEqual(mockProfile)
})
it('returns the created profile', async () => {
mockFetchApi.mockResolvedValue(
mockSuccessResponse({
username: 'testuser',
name: 'Test User',
description: 'A test profile',
cover_image_url: 'https://example.com/cover.png',
profile_picture_url: 'https://example.com/profile.png'
})
)
const profile = await gate.createProfile({ username: 'testuser' })
expect(profile).toEqual({
...mockProfile,
coverImageUrl: 'https://example.com/cover.png',
profilePictureUrl: 'https://example.com/profile.png'
expect(mockCreateProfile).toHaveBeenCalledWith({
workspaceId: 'workspace-1',
username: 'testuser',
displayName: 'Test User',
description: 'Hello',
avatarToken: 'avatar-token'
})
})
it('throws with error message from API response', async () => {
mockFetchApi.mockResolvedValue(mockErrorResponse(400, 'Username taken'))
it('uploads avatar via upload-url + PUT before create', async () => {
const profilePicture = new File(['img'], 'avatar.png', {
type: 'image/png'
})
await expect(gate.createProfile({ username: 'taken' })).rejects.toThrow(
'Username taken'
)
await gate.createProfile({
username: 'testuser',
profilePicture
})
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
filename: 'avatar.png',
contentType: 'image/png'
})
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
uploadUrl: 'https://upload.example.com/avatar.png',
file: profilePicture,
contentType: 'image/png'
})
const requestCallOrder =
mockRequestAssetUploadUrl.mock.invocationCallOrder
const uploadCallOrder =
mockUploadFileToPresignedUrl.mock.invocationCallOrder
const createCallOrder = mockCreateProfile.mock.invocationCallOrder
expect(requestCallOrder[0]).toBeLessThan(uploadCallOrder[0])
expect(uploadCallOrder[0]).toBeLessThan(createCallOrder[0])
})
})
})

View File

@@ -2,9 +2,9 @@ import { ref } from 'vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { zHubProfileResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
// TODO: Migrate to a Pinia store for proper singleton state management
// User-scoped, session-cached profile state (module-level singleton)
@@ -15,14 +15,43 @@ const profile = ref<ComfyHubProfile | null>(null)
const cachedUserId = ref<string | null>(null)
let inflightFetch: Promise<ComfyHubProfile | null> | null = null
function mapHubProfileResponse(payload: unknown): ComfyHubProfile | null {
const result = zHubProfileResponse.safeParse(payload)
return result.success ? result.data : null
function getCurrentWorkspaceId(): string {
const workspaceJson = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE
)
if (!workspaceJson) {
throw new Error('Unable to determine current workspace')
}
let workspace: unknown
try {
workspace = JSON.parse(workspaceJson)
} catch {
throw new Error('Unable to determine current workspace')
}
if (
!workspace ||
typeof workspace !== 'object' ||
!('id' in workspace) ||
typeof workspace.id !== 'string' ||
workspace.id.length === 0
) {
throw new Error('Unable to determine current workspace')
}
return workspace.id
}
export function useComfyHubProfileGate() {
const { resolvedUserInfo } = useCurrentUser()
const { toastErrorHandler } = useErrorHandling()
const {
getMyProfile,
requestAssetUploadUrl,
uploadFileToPresignedUrl,
createProfile: createComfyHubProfile
} = useComfyHubService()
function syncCachedProfileWithCurrentUser(): void {
const currentUserId = resolvedUserInfo.value?.id ?? null
@@ -38,14 +67,7 @@ export function useComfyHubProfileGate() {
async function performFetch(): Promise<ComfyHubProfile | null> {
isFetchingProfile.value = true
try {
const response = await api.fetchApi('/hub/profile')
if (!response.ok) {
hasProfile.value = false
profile.value = null
return null
}
const nextProfile = mapHubProfileResponse(await response.json())
const nextProfile = await getMyProfile()
if (!nextProfile) {
hasProfile.value = false
profile.value = null
@@ -95,37 +117,35 @@ export function useComfyHubProfileGate() {
username: string
name?: string
description?: string
coverImage?: File
profilePicture?: File
}): Promise<ComfyHubProfile> {
syncCachedProfileWithCurrentUser()
const formData = new FormData()
formData.append('username', data.username)
if (data.name) formData.append('name', data.name)
if (data.description) formData.append('description', data.description)
if (data.coverImage) formData.append('cover_image', data.coverImage)
if (data.profilePicture)
formData.append('profile_picture', data.profilePicture)
let avatarToken: string | undefined
if (data.profilePicture) {
const contentType = data.profilePicture.type || 'application/octet-stream'
const upload = await requestAssetUploadUrl({
filename: data.profilePicture.name,
contentType
})
const response = await api.fetchApi('/hub/profile', {
method: 'POST',
body: formData
await uploadFileToPresignedUrl({
uploadUrl: upload.uploadUrl,
file: data.profilePicture,
contentType
})
avatarToken = upload.token
}
const createdProfile = await createComfyHubProfile({
workspaceId: getCurrentWorkspaceId(),
username: data.username,
displayName: data.name,
description: data.description,
avatarToken
})
if (!response.ok) {
const body: unknown = await response.json().catch(() => ({}))
const message =
body && typeof body === 'object' && 'message' in body
? String((body as Record<string, unknown>).message)
: 'Failed to create profile'
throw new Error(message)
}
const createdProfile = mapHubProfileResponse(await response.json())
if (!createdProfile) {
throw new Error('Invalid profile response from server')
}
hasProfile.value = true
profile.value = createdProfile
return createdProfile

View File

@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
const mockGetShareableAssets = vi.hoisted(() => vi.fn())
const mockRequestAssetUploadUrl = vi.hoisted(() => vi.fn())
const mockUploadFileToPresignedUrl = vi.hoisted(() => vi.fn())
const mockPublishWorkflow = vi.hoisted(() => vi.fn())
const mockProfile = vi.hoisted(
() => ({ value: null }) as { value: ComfyHubProfile | null }
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
() => ({
useComfyHubProfileGate: () => ({
profile: mockProfile
})
})
)
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
useWorkflowShareService: () => ({
getShareableAssets: mockGetShareableAssets
})
}))
vi.mock('@/platform/workflow/sharing/services/comfyHubService', () => ({
useComfyHubService: () => ({
requestAssetUploadUrl: mockRequestAssetUploadUrl,
uploadFileToPresignedUrl: mockUploadFileToPresignedUrl,
publishWorkflow: mockPublishWorkflow
})
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: {
path: 'workflows/demo-workflow.json'
}
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => mockWorkflowStore
}))
const { useComfyHubPublishSubmission } =
await import('./useComfyHubPublishSubmission')
function createFormData(
overrides: Partial<ComfyHubPublishFormData> = {}
): ComfyHubPublishFormData {
return {
name: 'Demo workflow',
description: 'A demo workflow',
tags: ['demo'],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
tutorialUrl: '',
metadata: {},
...overrides
}
}
describe('useComfyHubPublishSubmission', () => {
beforeEach(() => {
vi.clearAllMocks()
mockProfile.value = {
username: 'builder',
name: 'Builder'
}
mockGetShareableAssets.mockResolvedValue([
{ id: 'asset-1' },
{ id: 'asset-2' }
])
let uploadIndex = 0
mockRequestAssetUploadUrl.mockImplementation(
async ({ filename }: { filename: string }) => {
uploadIndex += 1
return {
uploadUrl: `https://upload.example.com/${filename}`,
publicUrl: `https://cdn.example.com/${filename}`,
token: `token-${uploadIndex}`
}
}
)
mockUploadFileToPresignedUrl.mockResolvedValue(undefined)
mockPublishWorkflow.mockResolvedValue({
share_id: 'share-1',
workflow_id: 'workflow-1'
})
})
it('maps imageComparison to image_comparison', async () => {
const beforeFile = new File(['before'], 'before.png', { type: 'image/png' })
const afterFile = new File(['after'], 'after.png', { type: 'image/png' })
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'imageComparison',
thumbnailFile: null,
comparisonBeforeFile: beforeFile,
comparisonAfterFile: afterFile
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailType: 'image_comparison'
})
)
})
it('uploads thumbnail and returns thumbnail token', async () => {
const thumbnailFile = new File(['thumbnail'], 'thumb.png', {
type: 'image/png'
})
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile
})
)
expect(mockRequestAssetUploadUrl).toHaveBeenCalledWith({
filename: 'thumb.png',
contentType: 'image/png'
})
expect(mockUploadFileToPresignedUrl).toHaveBeenCalledWith({
uploadUrl: 'https://upload.example.com/thumb.png',
file: thumbnailFile,
contentType: 'image/png'
})
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'token-1'
})
)
})
it('uploads all example images', async () => {
const file1 = new File(['img1'], 'img1.png', { type: 'image/png' })
const file2 = new File(['img2'], 'img2.png', { type: 'image/png' })
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile: null,
exampleImages: [
{ id: 'a', file: file1, url: 'blob:a' },
{ id: 'b', file: file2, url: 'blob:b' }
]
})
)
expect(mockRequestAssetUploadUrl).toHaveBeenCalledTimes(2)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
sampleImageTokensOrUrls: ['token-1', 'token-2']
})
)
})
it('builds publish request with workflow filename + asset ids', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(createFormData())
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
username: 'builder',
workflowFilename: 'workflows/demo-workflow.json',
assetIds: ['asset-1', 'asset-2'],
name: 'Demo workflow',
description: 'A demo workflow',
tags: ['demo']
})
)
})
it('throws when profile username is unavailable', async () => {
mockProfile.value = null
const { submitToComfyHub } = useComfyHubPublishSubmission()
await expect(submitToComfyHub(createFormData())).rejects.toThrow(
'ComfyHub profile is required before publishing'
)
})
})

View File

@@ -0,0 +1,129 @@
import type { AssetInfo, ComfyHubProfile } from '@/schemas/apiSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useComfyHubProfileGate } from '@/platform/workflow/sharing/composables/useComfyHubProfileGate'
import { useComfyHubService } from '@/platform/workflow/sharing/services/comfyHubService'
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import type {
ComfyHubApiThumbnailType,
ComfyHubPublishFormData,
ThumbnailType
} from '@/platform/workflow/sharing/types/comfyHubTypes'
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
function mapThumbnailType(type: ThumbnailType): ComfyHubApiThumbnailType {
if (type === 'imageComparison') {
return 'image_comparison'
}
return type
}
function getFileContentType(file: File): string {
return file.type || 'application/octet-stream'
}
function hasExampleContent(formData: ComfyHubPublishFormData): boolean {
return formData.exampleImages.length > 0
}
function getUsername(profile: ComfyHubProfile | null): string {
const username = profile?.username?.trim()
if (!username) {
throw new Error('ComfyHub profile is required before publishing')
}
return username
}
function getWorkflowFilename(path: string | null | undefined): string {
const workflowFilename = path?.trim()
if (!workflowFilename) {
throw new Error('No active workflow file available for publishing')
}
return workflowFilename
}
function getAssetIds(assets: AssetInfo[]): string[] {
return assets.map((asset) => asset.id)
}
export function useComfyHubPublishSubmission() {
const { profile } = useComfyHubProfileGate()
const workflowStore = useWorkflowStore()
const workflowShareService = useWorkflowShareService()
const comfyHubService = useComfyHubService()
async function uploadFileAndGetToken(file: File): Promise<string> {
const contentType = getFileContentType(file)
const upload = await comfyHubService.requestAssetUploadUrl({
filename: file.name,
contentType
})
await comfyHubService.uploadFileToPresignedUrl({
uploadUrl: upload.uploadUrl,
file,
contentType
})
return upload.token
}
async function submitToComfyHub(
formData: ComfyHubPublishFormData
): Promise<void> {
const username = getUsername(profile.value)
const workflowFilename = getWorkflowFilename(
workflowStore.activeWorkflow?.path
)
const assetIds = getAssetIds(
await workflowShareService.getShareableAssets()
)
const thumbnailType = mapThumbnailType(formData.thumbnailType)
const thumbnailTokenOrUrl =
formData.thumbnailFile && thumbnailType !== 'image_comparison'
? await uploadFileAndGetToken(formData.thumbnailFile)
: formData.comparisonBeforeFile
? await uploadFileAndGetToken(formData.comparisonBeforeFile)
: undefined
const thumbnailComparisonTokenOrUrl =
thumbnailType === 'image_comparison' && formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
: undefined
const sampleImageTokensOrUrls = hasExampleContent(formData)
? await Promise.all(
formData.exampleImages.map((image) =>
image.file ? uploadFileAndGetToken(image.file) : image.url
)
)
: undefined
await comfyHubService.publishWorkflow({
username,
name: formData.name,
workflowFilename,
assetIds,
description: formData.description || undefined,
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
models: formData.models.length > 0 ? formData.models : undefined,
customNodes:
formData.customNodes.length > 0 ? formData.customNodes : undefined,
thumbnailType,
thumbnailTokenOrUrl,
thumbnailComparisonTokenOrUrl,
sampleImageTokensOrUrls,
tutorialUrl: formData.tutorialUrl || undefined,
metadata:
Object.keys(formData.metadata).length > 0
? formData.metadata
: undefined
})
}
return {
submitToComfyHub
}
}

View File

@@ -35,14 +35,12 @@ describe('useComfyHubPublishWizard', () => {
it('initialises all other form fields to defaults', () => {
const { formData } = useComfyHubPublishWizard()
expect(formData.value.description).toBe('')
expect(formData.value.workflowType).toBe('')
expect(formData.value.tags).toEqual([])
expect(formData.value.thumbnailType).toBe('image')
expect(formData.value.thumbnailFile).toBeNull()
expect(formData.value.comparisonBeforeFile).toBeNull()
expect(formData.value.comparisonAfterFile).toBeNull()
expect(formData.value.exampleImages).toEqual([])
expect(formData.value.selectedExampleIds).toEqual([])
})
})

View File

@@ -1,8 +1,14 @@
import { useStepper } from '@vueuse/core'
import { v4 as uuidv4 } from 'uuid'
import { computed, ref } from 'vue'
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
import type {
ComfyHubPublishFormData,
ExampleImage
} from '@/platform/workflow/sharing/types/comfyHubTypes'
import type { PublishPrefill } from '@/platform/workflow/sharing/types/shareTypes'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
const PUBLISH_STEPS = [
'describe',
@@ -13,22 +19,54 @@ const PUBLISH_STEPS = [
export type ComfyHubPublishStep = (typeof PUBLISH_STEPS)[number]
const cachedPrefills = new Map<string, PublishPrefill>()
function createDefaultFormData(): ComfyHubPublishFormData {
const { activeWorkflow } = useWorkflowStore()
const workflowStore = useWorkflowStore()
return {
name: activeWorkflow?.filename ?? '',
name: workflowStore.activeWorkflow?.filename ?? '',
description: '',
workflowType: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
selectedExampleIds: []
tutorialUrl: '',
metadata: {}
}
}
function createExampleImagesFromUrls(urls: string[]): ExampleImage[] {
return urls.map((url) => ({ id: uuidv4(), url }))
}
function extractPrefillFromFormData(
formData: ComfyHubPublishFormData
): PublishPrefill {
return {
description: formData.description || undefined,
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
thumbnailType: formData.thumbnailType,
sampleImageUrls: formData.exampleImages
.map((img) => img.url)
.filter((url) => !url.startsWith('blob:'))
}
}
export function cachePublishPrefill(
workflowPath: string,
formData: ComfyHubPublishFormData
) {
cachedPrefills.set(workflowPath, extractPrefillFromFormData(formData))
}
export function getCachedPrefill(workflowPath: string): PublishPrefill | null {
return cachedPrefills.get(workflowPath) ?? null
}
export function useComfyHubPublishWizard() {
const stepper = useStepper([...PUBLISH_STEPS])
const formData = ref<ComfyHubPublishFormData>(createDefaultFormData())
@@ -53,6 +91,30 @@ export function useComfyHubPublishWizard() {
stepper.goTo('finish')
}
function applyPrefill(prefill: PublishPrefill) {
const defaults = createDefaultFormData()
const current = formData.value
formData.value = {
...current,
description:
current.description === defaults.description
? (prefill.description ?? current.description)
: current.description,
tags:
current.tags.length === 0 && prefill.tags?.length
? prefill.tags
: current.tags,
thumbnailType:
current.thumbnailType === defaults.thumbnailType
? (prefill.thumbnailType ?? current.thumbnailType)
: current.thumbnailType,
exampleImages:
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
? createExampleImagesFromUrls(prefill.sampleImageUrls)
: current.exampleImages
}
}
return {
currentStep: stepper.current,
formData,
@@ -64,6 +126,7 @@ export function useComfyHubPublishWizard() {
goNext: stepper.goToNext,
goBack: stepper.goToPrevious,
openProfileCreationStep,
closeProfileCreationStep
closeProfileCreationStep,
applyPrefill
}
}

View File

@@ -53,7 +53,6 @@ const COMFY_HUB_TAG_FREQUENCIES = [
{ tag: 'Lip Sync', count: 2 },
{ tag: 'Multiple Angles', count: 2 },
{ tag: 'Remove Background', count: 2 },
{ tag: 'Text-to-Image', count: 2 },
{ tag: 'Vector', count: 2 },
{ tag: 'Brand', count: 1 },
{ tag: 'Canny', count: 1 },

View File

@@ -10,6 +10,15 @@ export const zPublishRecordResponse = z.object({
assets: z.array(zAssetInfo).optional()
})
export const zHubWorkflowPrefillResponse = z.object({
description: z.string().nullish(),
tags: z.array(z.string()).nullish(),
sample_image_urls: z.array(z.string()).nullish(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
thumbnail_url: z.string().nullish(),
thumbnail_comparison_url: z.string().nullish()
})
/**
* Strips path separators and control characters from a workflow name to prevent
* path traversal when the name is later used as part of a file path.
@@ -36,9 +45,28 @@ export const zHubProfileResponse = z.preprocess((data) => {
const d = data as Record<string, unknown>
return {
username: d.username,
name: d.name,
name: d.name ?? d.display_name,
description: d.description,
coverImageUrl: d.coverImageUrl ?? d.cover_image_url,
profilePictureUrl: d.profilePictureUrl ?? d.profile_picture_url
profilePictureUrl:
d.profilePictureUrl ?? d.profile_picture_url ?? d.avatar_url
}
}, zComfyHubProfile)
export const zHubAssetUploadUrlResponse = z
.object({
upload_url: z.string(),
public_url: z.string(),
token: z.string()
})
.transform((response) => ({
uploadUrl: response.upload_url,
publicUrl: response.public_url,
token: response.token
}))
export const zHubWorkflowPublishResponse = z.object({
share_id: z.string(),
workflow_id: z.string(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).optional()
})

View File

@@ -0,0 +1,198 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const mockFetchApi = vi.hoisted(() => vi.fn())
const mockGlobalFetch = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
}
}))
const { useComfyHubService } = await import('./comfyHubService')
function mockJsonResponse(payload: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => payload
} as Response
}
function mockUploadResponse(ok = true, status = 200): Response {
return {
ok,
status,
json: async () => ({})
} as Response
}
describe('useComfyHubService', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.stubGlobal('fetch', mockGlobalFetch)
})
afterAll(() => {
vi.unstubAllGlobals()
})
it('requests upload url and returns token payload', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
upload_url: 'https://upload.example.com/object',
public_url: 'https://cdn.example.com/object',
token: 'upload-token'
})
)
const service = useComfyHubService()
const result = await service.requestAssetUploadUrl({
filename: 'thumb.png',
contentType: 'image/png'
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/assets/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: 'thumb.png',
content_type: 'image/png'
})
})
expect(result).toEqual({
uploadUrl: 'https://upload.example.com/object',
publicUrl: 'https://cdn.example.com/object',
token: 'upload-token'
})
})
it('uploads file to presigned url with PUT', async () => {
mockGlobalFetch.mockResolvedValue(mockUploadResponse())
const service = useComfyHubService()
const file = new File(['payload'], 'avatar.png', { type: 'image/png' })
await service.uploadFileToPresignedUrl({
uploadUrl: 'https://upload.example.com/object',
file,
contentType: 'image/png'
})
expect(mockGlobalFetch).toHaveBeenCalledWith(
'https://upload.example.com/object',
{
method: 'PUT',
headers: {
'Content-Type': 'image/png'
},
body: file
}
)
})
it('creates profile with workspace_id JSON body', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
id: 'profile-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_url: 'https://cdn.example.com/avatar.png',
website_urls: []
})
)
const service = useComfyHubService()
const profile = await service.createProfile({
workspaceId: 'workspace-1',
username: 'builder',
displayName: 'Builder',
description: 'Builds workflows',
avatarToken: 'avatar-token'
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspace_id: 'workspace-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_token: 'avatar-token'
})
})
expect(profile).toEqual({
username: 'builder',
name: 'Builder',
description: 'Builds workflows',
profilePictureUrl: 'https://cdn.example.com/avatar.png',
coverImageUrl: undefined
})
})
it('publishes workflow with mapped thumbnail enum', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
share_id: 'share-1',
workflow_id: 'workflow-1',
thumbnail_type: 'image_comparison'
})
)
const service = useComfyHubService()
await service.publishWorkflow({
username: 'builder',
name: 'My Flow',
workflowFilename: 'workflows/my-flow.json',
assetIds: ['asset-1'],
thumbnailType: 'imageComparison',
thumbnailTokenOrUrl: 'thumb-token',
thumbnailComparisonTokenOrUrl: 'thumb-compare-token',
sampleImageTokensOrUrls: ['sample-1']
})
const [, options] = mockFetchApi.mock.calls[0]
const body = JSON.parse(options.body as string)
expect(body).toMatchObject({
username: 'builder',
name: 'My Flow',
workflow_filename: 'workflows/my-flow.json',
asset_ids: ['asset-1'],
thumbnail_type: 'image_comparison',
thumbnail_token_or_url: 'thumb-token',
thumbnail_comparison_token_or_url: 'thumb-compare-token',
sample_image_tokens_or_urls: ['sample-1']
})
expect(mockFetchApi).toHaveBeenCalledWith('/hub/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
})
it('fetches current profile from /hub/profiles/me', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
id: 'profile-1',
username: 'builder',
display_name: 'Builder',
description: 'Builds workflows',
avatar_url: 'https://cdn.example.com/avatar.png',
website_urls: []
})
)
const service = useComfyHubService()
const profile = await service.getMyProfile()
expect(mockFetchApi).toHaveBeenCalledWith('/hub/profiles/me')
expect(profile).toEqual({
username: 'builder',
name: 'Builder',
description: 'Builds workflows',
profilePictureUrl: 'https://cdn.example.com/avatar.png',
coverImageUrl: undefined
})
})
})

View File

@@ -0,0 +1,219 @@
import type { ComfyHubProfile } from '@/schemas/apiSchema'
import {
zHubAssetUploadUrlResponse,
zHubProfileResponse,
zHubWorkflowPublishResponse
} from '@/platform/workflow/sharing/schemas/shareSchemas'
import { api } from '@/scripts/api'
type HubThumbnailType = 'image' | 'video' | 'image_comparison'
type ThumbnailTypeInput = HubThumbnailType | 'imageComparison'
interface CreateProfileInput {
workspaceId: string
username: string
displayName?: string
description?: string
avatarToken?: string
}
interface PublishWorkflowInput {
username: string
name: string
workflowFilename: string
assetIds: string[]
description?: string
tags?: string[]
models?: string[]
customNodes?: string[]
thumbnailType?: ThumbnailTypeInput
thumbnailTokenOrUrl?: string
thumbnailComparisonTokenOrUrl?: string
sampleImageTokensOrUrls?: string[]
tutorialUrl?: string
metadata?: Record<string, unknown>
}
function normalizeThumbnailType(type: ThumbnailTypeInput): HubThumbnailType {
if (type === 'imageComparison') {
return 'image_comparison'
}
return type
}
async function parseErrorMessage(
response: Response,
fallbackMessage: string
): Promise<string> {
const body = await response.json().catch(() => null)
if (!body || typeof body !== 'object') {
return fallbackMessage
}
if ('message' in body && typeof body.message === 'string') {
return body.message
}
return fallbackMessage
}
async function parseRequiredJson<T>(
response: Response,
parser: {
safeParse: (
value: unknown
) => { success: true; data: T } | { success: false }
},
fallbackMessage: string
): Promise<T> {
const payload = await response.json().catch(() => null)
const parsed = parser.safeParse(payload)
if (!parsed.success) {
throw new Error(fallbackMessage)
}
return parsed.data
}
export function useComfyHubService() {
async function requestAssetUploadUrl(input: {
filename: string
contentType: string
}) {
const response = await api.fetchApi('/hub/assets/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: input.filename,
content_type: input.contentType
})
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to request upload URL')
)
}
return parseRequiredJson(
response,
zHubAssetUploadUrlResponse,
'Invalid upload URL response from server'
)
}
async function uploadFileToPresignedUrl(input: {
uploadUrl: string
file: File
contentType: string
}): Promise<void> {
const response = await fetch(input.uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': input.contentType
},
body: input.file
})
if (!response.ok) {
throw new Error('Failed to upload file')
}
}
async function getMyProfile(): Promise<ComfyHubProfile | null> {
const response = await api.fetchApi('/hub/profiles/me')
if (!response.ok) {
if (response.status === 404) {
return null
}
throw new Error(
await parseErrorMessage(response, 'Failed to load ComfyHub profile')
)
}
return parseRequiredJson(
response,
zHubProfileResponse,
'Invalid profile response from server'
)
}
async function createProfile(
input: CreateProfileInput
): Promise<ComfyHubProfile> {
const response = await api.fetchApi('/hub/profiles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workspace_id: input.workspaceId,
username: input.username,
display_name: input.displayName,
description: input.description,
avatar_token: input.avatarToken
})
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to create ComfyHub profile')
)
}
return parseRequiredJson(
response,
zHubProfileResponse,
'Invalid profile response from server'
)
}
async function publishWorkflow(input: PublishWorkflowInput) {
const body = {
username: input.username,
name: input.name,
workflow_filename: input.workflowFilename,
asset_ids: input.assetIds,
description: input.description,
tags: input.tags,
models: input.models,
custom_nodes: input.customNodes,
thumbnail_type: input.thumbnailType
? normalizeThumbnailType(input.thumbnailType)
: undefined,
thumbnail_token_or_url: input.thumbnailTokenOrUrl,
thumbnail_comparison_token_or_url: input.thumbnailComparisonTokenOrUrl,
sample_image_tokens_or_urls: input.sampleImageTokensOrUrls,
tutorial_url: input.tutorialUrl,
metadata: input.metadata
}
const response = await api.fetchApi('/hub/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!response.ok) {
throw new Error(
await parseErrorMessage(response, 'Failed to publish workflow')
)
}
return parseRequiredJson(
response,
zHubWorkflowPublishResponse,
'Invalid publish response from server'
)
}
return {
requestAssetUploadUrl,
uploadFileToPresignedUrl,
getMyProfile,
createProfile,
publishWorkflow
}
}

View File

@@ -163,6 +163,82 @@ describe(useWorkflowShareService, () => {
expect(status.publishedAt).toBeInstanceOf(Date)
})
it('includes prefill data from hub workflow details', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-prefill/publish') {
return mockJsonResponse({
workflow_id: 'wf-prefill',
share_id: 'wf-prefill',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
if (path === '/hub/workflows/wf-prefill') {
return mockJsonResponse({
description: 'A cool workflow',
tags: ['art', 'upscale'],
thumbnail_type: 'image_comparison',
sample_image_urls: ['https://example.com/img1.png']
})
}
return mockJsonResponse({}, false, 404)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-prefill')
expect(status.isPublished).toBe(true)
expect(status.prefill).toEqual({
description: 'A cool workflow',
tags: ['art', 'upscale'],
thumbnailType: 'imageComparison',
sampleImageUrls: ['https://example.com/img1.png']
})
expect(mockFetchApi).toHaveBeenNthCalledWith(2, '/hub/workflows/wf-prefill')
})
it('returns null prefill when hub workflow details are unavailable', async () => {
mockFetchApi.mockImplementation(async (path: string) => {
if (path === '/userdata/wf-no-meta/publish') {
return mockJsonResponse({
workflow_id: 'wf-no-meta',
share_id: 'wf-no-meta',
publish_time: '2026-02-23T00:00:00Z',
listed: true
})
}
return mockJsonResponse({}, false, 500)
})
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-no-meta')
expect(status.isPublished).toBe(true)
expect(status.prefill).toBeNull()
})
it('does not fetch hub workflow details when publish record is unlisted', async () => {
mockFetchApi.mockResolvedValue(
mockJsonResponse({
workflow_id: 'wf-unlisted',
share_id: 'wf-unlisted',
publish_time: '2026-02-23T00:00:00Z',
listed: false
})
)
const service = useWorkflowShareService()
const status = await service.getPublishStatus('wf-unlisted')
expect(status.isPublished).toBe(true)
expect(status.prefill).toBeNull()
expect(mockFetchApi).toHaveBeenCalledTimes(1)
expect(mockFetchApi).toHaveBeenCalledWith('/userdata/wf-unlisted/publish')
})
it('preserves app subpath when normalizing publish status share URLs', async () => {
window.history.replaceState({}, '', '/comfy/subpath/')
mockFetchApi.mockResolvedValue(
@@ -303,7 +379,8 @@ describe(useWorkflowShareService, () => {
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null
publishedAt: null,
prefill: null
})
})

View File

@@ -1,12 +1,15 @@
import type {
PublishPrefill,
SharedWorkflowPayload,
WorkflowPublishResult,
WorkflowPublishStatus
} from '@/platform/workflow/sharing/types/shareTypes'
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { AssetInfo } from '@/schemas/apiSchema'
import {
zHubWorkflowPrefillResponse,
zPublishRecordResponse,
zSharedWorkflowResponse
} from '@/platform/workflow/sharing/schemas/shareSchemas'
@@ -28,6 +31,45 @@ class SharedWorkflowLoadError extends Error {
}
}
function mapApiThumbnailType(
value: 'image' | 'video' | 'image_comparison' | null | undefined
): ThumbnailType | undefined {
if (!value) return undefined
if (value === 'image_comparison') return 'imageComparison'
return value
}
interface PrefillMetadataFields {
description?: string | null
tags?: string[] | null
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
sample_image_urls?: string[] | null
}
function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
const description = fields.description ?? undefined
const tags = fields.tags ?? undefined
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
const sampleImageUrls = fields.sample_image_urls ?? undefined
if (
!description &&
!tags?.length &&
!thumbnailType &&
!sampleImageUrls?.length
) {
return null
}
return { description, tags, thumbnailType, sampleImageUrls }
}
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {
const result = zHubWorkflowPrefillResponse.safeParse(payload)
if (!result.success) return null
return extractPrefill(result.data)
}
function decodePublishRecord(payload: unknown) {
const result = zPublishRecordResponse.safeParse(payload)
if (!result.success) return null
@@ -37,7 +79,8 @@ function decodePublishRecord(payload: unknown) {
shareId: r.share_id ?? undefined,
listed: r.listed,
publishedAt: parsePublishedAt(r.publish_time),
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined
shareUrl: r.share_id ? normalizeShareUrl(r.share_id) : undefined,
prefill: null
}
}
@@ -81,10 +124,27 @@ const UNPUBLISHED = {
isPublished: false,
shareId: null,
shareUrl: null,
publishedAt: null
publishedAt: null,
prefill: null
} as const satisfies WorkflowPublishStatus
export function useWorkflowShareService() {
async function fetchHubWorkflowPrefill(
shareId: string
): Promise<PublishPrefill | null> {
const response = await api.fetchApi(
`/hub/workflows/${encodeURIComponent(shareId)}`
)
if (!response.ok) {
throw new Error(
`Failed to fetch hub workflow details: ${response.status}`
)
}
const prefill = decodeHubWorkflowPrefill(await response.json())
return prefill
}
async function publishWorkflow(
workflowPath: string,
shareableAssets: AssetInfo[]
@@ -132,11 +192,21 @@ export function useWorkflowShareService() {
const record = decodePublishRecord(json)
if (!record || !record.shareId || !record.publishedAt) return UNPUBLISHED
let prefill: PublishPrefill | null = record.prefill
if (!prefill && record.listed) {
try {
prefill = await fetchHubWorkflowPrefill(record.shareId)
} catch {
prefill = null
}
}
return {
isPublished: true,
shareId: record.shareId,
shareUrl: normalizeShareUrl(record.shareId),
publishedAt: record.publishedAt
publishedAt: record.publishedAt,
prefill
}
}

View File

@@ -1,10 +1,6 @@
export type ThumbnailType = 'image' | 'video' | 'imageComparison'
export type ComfyHubWorkflowType =
| 'imageGeneration'
| 'videoGeneration'
| 'upscaling'
| 'editing'
export type ComfyHubApiThumbnailType = 'image' | 'video' | 'image_comparison'
export interface ExampleImage {
id: string
@@ -15,12 +11,14 @@ export interface ExampleImage {
export interface ComfyHubPublishFormData {
name: string
description: string
workflowType: ComfyHubWorkflowType | ''
tags: string[]
models: string[]
customNodes: string[]
thumbnailType: ThumbnailType
thumbnailFile: File | null
comparisonBeforeFile: File | null
comparisonAfterFile: File | null
exampleImages: ExampleImage[]
selectedExampleIds: string[]
tutorialUrl: string
metadata: Record<string, unknown>
}

View File

@@ -1,5 +1,6 @@
import type { AssetInfo } from '@/schemas/apiSchema'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
export interface WorkflowPublishResult {
publishedAt: Date
@@ -7,13 +8,27 @@ export interface WorkflowPublishResult {
shareUrl: string
}
export interface PublishPrefill {
description?: string
tags?: string[]
thumbnailType?: ThumbnailType
sampleImageUrls?: string[]
}
export type WorkflowPublishStatus =
| { isPublished: false; publishedAt: null; shareId: null; shareUrl: null }
| {
isPublished: false
publishedAt: null
shareId: null
shareUrl: null
prefill: null
}
| {
isPublished: true
publishedAt: Date
shareId: string
shareUrl: string
prefill: PublishPrefill | null
}
export interface SharedWorkflowPayload {

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { normalizeTag, normalizeTags } from './normalizeTags'
describe('normalizeTag', () => {
it.for([
{ input: 'Text to Image', expected: 'text-to-image', name: 'spaces' },
{ input: 'API', expected: 'api', name: 'single word' },
{
input: 'text-to-image',
expected: 'text-to-image',
name: 'already normalized'
},
{
input: 'Image Upscale',
expected: 'image-upscale',
name: 'multiple spaces'
},
{
input: ' Video ',
expected: 'video',
name: 'leading/trailing whitespace'
},
{ input: ' ', expected: '', name: 'whitespace-only' }
])('$name: "$input" → "$expected"', ({ input, expected }) => {
expect(normalizeTag(input)).toBe(expected)
})
})
describe('normalizeTags', () => {
it.for([
{
name: 'normalizes all tags',
input: ['Text to Image', 'API', 'Video'],
expected: ['text-to-image', 'api', 'video']
},
{
name: 'deduplicates tags with the same slug',
input: ['Text to Image', 'Text-to-Image'],
expected: ['text-to-image']
},
{
name: 'filters out empty tags',
input: ['Video', '', ' ', 'Audio'],
expected: ['video', 'audio']
},
{
name: 'returns empty array for empty input',
input: [],
expected: []
}
])('$name', ({ input, expected }) => {
expect(normalizeTags(input)).toEqual(expected)
})
})

View File

@@ -0,0 +1,14 @@
/**
* Normalizes a tag to its slug form for the ComfyHub API.
* Converts display names like "Text to Image" to "text-to-image".
*/
export function normalizeTag(tag: string): string {
return tag.trim().toLowerCase().replace(/\s+/g, '-')
}
/**
* Normalizes and deduplicates an array of tags for API submission.
*/
export function normalizeTags(tags: string[]): string[] {
return [...new Set(tags.map(normalizeTag).filter(Boolean))]
}

View File

@@ -579,6 +579,9 @@ export class ComfyApp {
// Get prompt from dropped PNG or json
useEventListener(document, 'drop', async (event: DragEvent) => {
try {
// Skip if already handled (e.g. file drop onto publish dialog tiles)
if (event.defaultPrevented) return
event.preventDefault()
event.stopPropagation()

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { MODEL_NODE_MAPPINGS } from '@/platform/assets/mappings/modelNodeMappings'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -157,9 +156,254 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
}
haveDefaultsLoaded.value = true
for (const [modelType, nodeClass, key] of MODEL_NODE_MAPPINGS) {
quickRegister(modelType, nodeClass, key)
}
quickRegister('checkpoints', 'CheckpointLoaderSimple', 'ckpt_name')
quickRegister('checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name')
quickRegister('loras', 'LoraLoader', 'lora_name')
quickRegister('loras', 'LoraLoaderModelOnly', 'lora_name')
quickRegister('vae', 'VAELoader', 'vae_name')
quickRegister('controlnet', 'ControlNetLoader', 'control_net_name')
quickRegister('diffusion_models', 'UNETLoader', 'unet_name')
quickRegister('upscale_models', 'UpscaleModelLoader', 'model_name')
quickRegister('style_models', 'StyleModelLoader', 'style_model_name')
quickRegister('gligen', 'GLIGENLoader', 'gligen_name')
quickRegister('clip_vision', 'CLIPVisionLoader', 'clip_name')
quickRegister('text_encoders', 'CLIPLoader', 'clip_name')
quickRegister('audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name')
quickRegister('model_patches', 'ModelPatchLoader', 'name')
quickRegister(
'animatediff_models',
'ADE_LoadAnimateDiffModel',
'model_name'
)
quickRegister(
'animatediff_motion_lora',
'ADE_AnimateDiffLoRALoader',
'name'
)
// Chatterbox TTS nodes: empty key means the node auto-loads models without
// a widget selector (createModelNodeFromAsset skips widget assignment)
quickRegister('chatterbox/chatterbox', 'FL_ChatterboxTTS', '')
quickRegister('chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', '')
quickRegister(
'chatterbox/chatterbox_multilingual',
'FL_ChatterboxMultilingualTTS',
''
)
quickRegister('chatterbox/chatterbox_vc', 'FL_ChatterboxVC', '')
// Latent upscale models (ComfyUI core - nodes_hunyuan.py)
quickRegister(
'latent_upscale_models',
'LatentUpscaleModelLoader',
'model_name'
)
// SAM/SAM2 segmentation models (comfyui-segment-anything-2, comfyui-impact-pack)
quickRegister('sam2', 'DownloadAndLoadSAM2Model', 'model')
quickRegister('sams', 'SAMLoader', 'model_name')
// Ultralytics detection models (comfyui-impact-subpack)
// Note: ultralytics/bbox and ultralytics/segm fall back to this via hierarchical lookup
quickRegister('ultralytics', 'UltralyticsDetectorProvider', 'model_name')
// DepthAnything models (comfyui-depthanythingv2)
quickRegister(
'depthanything',
'DownloadAndLoadDepthAnythingV2Model',
'model'
)
// IP-Adapter models (comfyui_ipadapter_plus)
quickRegister('ipadapter', 'IPAdapterModelLoader', 'ipadapter_file')
// Segformer clothing/fashion segmentation models (comfyui_layerstyle)
quickRegister('segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name')
quickRegister('segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name')
quickRegister('segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name')
// NLF pose estimation models (ComfyUI-WanVideoWrapper)
quickRegister('nlf', 'LoadNLFModel', 'nlf_model')
// FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast)
// Empty key means the node auto-loads models without a widget selector
quickRegister('FlashVSR', 'FlashVSRNode', '')
quickRegister('FlashVSR-v1.1', 'FlashVSRNode', '')
// SEEDVR2 video upscaling (comfyui-seedvr2)
quickRegister('SEEDVR2', 'SeedVR2LoadDiTModel', 'model')
// Qwen VL vision-language models (comfyui-qwen-vl)
// Register each specific path to avoid LLM fallback catching unrelated models
// (e.g., LLM/llava-* should NOT map to AILab_QwenVL)
quickRegister(
'LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-2B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-2B-Thinking',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-4B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-4B-Thinking',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-8B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-8B-Thinking',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-32B-Instruct',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-VL-32B-Thinking',
'AILab_QwenVL',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-0.6B',
'AILab_QwenVL_PromptEnhancer',
'model_name'
)
quickRegister(
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
'AILab_QwenVL_PromptEnhancer',
'model_name'
)
quickRegister('LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint')
// Qwen3 TTS speech models (ComfyUI-FunBox)
// Top-level 'qwen-tts' catches all qwen-tts/* subdirs via hierarchical fallback
quickRegister('qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice')
// DepthAnything V3 models (comfyui-depthanythingv2)
quickRegister(
'depthanything3',
'DownloadAndLoadDepthAnythingV3Model',
'model'
)
// LivePortrait face animation models (comfyui-liveportrait)
quickRegister('liveportrait', 'DownloadAndLoadLivePortraitModels', '')
// MimicMotion video generation models (ComfyUI-MimicMotionWrapper)
quickRegister('mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model')
quickRegister('dwpose', 'MimicMotionGetPoses', '')
// Face parsing segmentation models (comfyui_face_parsing)
quickRegister('face_parsing', 'FaceParsingModelLoader(FaceParsing)', '')
// Kolors image generation models (ComfyUI-KolorsWrapper)
// Top-level 'diffusers' catches diffusers/Kolors/* subdirs
quickRegister('diffusers', 'DownloadAndLoadKolorsModel', 'model')
// CLIP models for HunyuanVideo (clip/clip-vit-large-patch14 subdir)
quickRegister('clip', 'CLIPVisionLoader', 'clip_name')
// RIFE video frame interpolation (ComfyUI-RIFE)
quickRegister('rife', 'RIFE VFI', 'ckpt_name')
// SAM3 3D segmentation models (comfyui-sam3)
quickRegister('sam3', 'LoadSAM3Model', 'model_path')
// UltraShape 3D model generation
quickRegister('UltraShape', 'UltraShapeLoadModel', 'checkpoint')
// SHaRP depth estimation
quickRegister('sharp', 'LoadSharpModel', 'checkpoint_path')
// ONNX upscale models (used by OnnxDetectionModelLoader and upscale nodes)
quickRegister('onnx', 'UpscaleModelLoader', 'model_name')
// Detection models (vitpose, yolo)
quickRegister('detection', 'OnnxDetectionModelLoader', 'yolo_model')
// HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper)
quickRegister(
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
'DownloadAndLoadHyVideoTextEncoder',
'llm_model'
)
quickRegister(
'LLM/llava-llama-3-8b-v1_1-transformers',
'DownloadAndLoadHyVideoTextEncoder',
'llm_model'
)
// CogVideoX models (comfyui-cogvideoxwrapper)
quickRegister('CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model')
// Empty key: HF-download node — don't activate asset browser for the combo widget
quickRegister(
'CogVideo/ControlNet',
'DownloadAndLoadCogVideoControlNet',
''
)
// DynamiCrafter models (ComfyUI-DynamiCrafterWrapper)
quickRegister(
'checkpoints/dynamicrafter',
'DownloadAndLoadDynamiCrafterModel',
'model'
)
quickRegister(
'checkpoints/dynamicrafter/controlnet',
'DownloadAndLoadDynamiCrafterCNModel',
'model'
)
// LayerStyle models (ComfyUI_LayerStyle_Advance)
quickRegister('BEN', 'LS_LoadBenModel', 'model')
quickRegister('BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model')
quickRegister('onnx/human-parts', 'LS_HumanPartsUltra', '')
quickRegister('lama', 'LaMa', 'lama_model')
// CogVideoX video generation models (comfyui-cogvideoxwrapper)
// Empty key: HF-download node — don't activate asset browser for the combo widget
quickRegister('CogVideo', 'DownloadAndLoadCogVideoModel', '')
// Inpaint models (comfyui-inpaint-nodes)
quickRegister('inpaint', 'INPAINT_LoadInpaintModel', 'model_name')
// LayerDiffuse transparent image generation (comfyui-layerdiffuse)
quickRegister('layer_model', 'LayeredDiffusionApply', 'config')
// LTX Video prompt enhancer models (ComfyUI-LTXTricks)
quickRegister(
'LLM/Llama-3.2-3B-Instruct',
'LTXVPromptEnhancerLoader',
'llm_name'
)
quickRegister(
'LLM/Florence-2-large-PromptGen-v2.0',
'LTXVPromptEnhancerLoader',
'image_captioner_name'
)
}
return {