mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-19 11:57:31 +00:00
Compare commits
19 Commits
refactor/m
...
fix/publis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9893a1572c | ||
|
|
a3325a9ebc | ||
|
|
bc811f2d73 | ||
|
|
3591579141 | ||
|
|
f7522bcaf6 | ||
|
|
2cf8e645db | ||
|
|
a82cead062 | ||
|
|
efc4e17ad2 | ||
|
|
1ae209d544 | ||
|
|
ebd6758e24 | ||
|
|
8b37638ee8 | ||
|
|
0264010b0f | ||
|
|
96d8f77ac9 | ||
|
|
0e0568ebe3 | ||
|
|
0e2c68e38a | ||
|
|
1270eea2bf | ||
|
|
61b9712660 | ||
|
|
9f546e22e4 | ||
|
|
e8b264d2ac |
@@ -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/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "3–42 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",
|
||||
|
||||
@@ -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]>
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
198
src/platform/workflow/sharing/services/comfyHubService.test.ts
Normal file
198
src/platform/workflow/sharing/services/comfyHubService.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
219
src/platform/workflow/sharing/services/comfyHubService.ts
Normal file
219
src/platform/workflow/sharing/services/comfyHubService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
55
src/platform/workflow/sharing/utils/normalizeTags.test.ts
Normal file
55
src/platform/workflow/sharing/utils/normalizeTags.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
14
src/platform/workflow/sharing/utils/normalizeTags.ts
Normal file
14
src/platform/workflow/sharing/utils/normalizeTags.ts
Normal 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))]
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user