From c39b388026d9a724c9d6ccdaf15535a11e5c5303 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Fri, 14 Nov 2025 13:53:28 +0900 Subject: [PATCH 1/6] feature: (WIP) useMediaAssetAction added --- .../assets/components/MediaAssetCard.vue | 1 - .../assets/components/MediaAssetMoreMenu.vue | 6 +- .../composables/useMediaAssetActions.ts | 271 ++++++++++++++++-- 3 files changed, 252 insertions(+), 26 deletions(-) diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index fd04336b80..42720f2e4e 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -37,7 +37,6 @@ :context="{ type: assetType }" @view="handleZoomClick" @download="actions.downloadAsset()" - @play="actions.playAsset(asset.id)" @video-playing-state-changed="isVideoPlaying = $event" @video-controls-changed="showVideoControls = $event" @image-loaded="handleImageLoaded" diff --git a/src/platform/assets/components/MediaAssetMoreMenu.vue b/src/platform/assets/components/MediaAssetMoreMenu.vue index 9f7242603c..5a43dd8613 100644 --- a/src/platform/assets/components/MediaAssetMoreMenu.vue +++ b/src/platform/assets/components/MediaAssetMoreMenu.vue @@ -129,7 +129,7 @@ const handleInspect = () => { const handleAddToWorkflow = () => { if (asset.value) { - actions.addWorkflow(asset.value.id) + actions.addWorkflow() } close() } @@ -143,14 +143,14 @@ const handleDownload = () => { const handleOpenWorkflow = () => { if (asset.value) { - actions.openWorkflow(asset.value.id) + actions.openWorkflow() } close() } const handleExportWorkflow = () => { if (asset.value) { - actions.exportWorkflow(asset.value.id) + actions.exportWorkflow() } close() } diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index d8d7b24363..5ba87296d9 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -1,24 +1,43 @@ -/* eslint-disable no-console */ import { useToast } from 'primevue/usetoast' import { inject } from 'vue' import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue' import { downloadFile } from '@/base/common/downloadUtil' +import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import { t } from '@/i18n' import { isCloud } from '@/platform/distribution/types' +import { useSettingStore } from '@/platform/settings/settingStore' +import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import { api } from '@/scripts/api' +import { getWorkflowDataFromFile } from '@/scripts/metadata/parser' +import { downloadBlob } from '@/scripts/utils' +import { useDialogService } from '@/services/dialogService' +import { useLitegraphService } from '@/services/litegraphService' +import { useNodeDefStore } from '@/stores/nodeDefStore' import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema' import { useAssetsStore } from '@/stores/assetsStore' import { useDialogStore } from '@/stores/dialogStore' +import { createAnnotatedPath } from '@/utils/createAnnotatedPath' +import { appendJsonExt } from '@/utils/formatUtil' import type { AssetItem } from '../schemas/assetSchema' import { MediaAssetKey } from '../schemas/mediaAssetSchema' import { assetService } from '../services/assetService' +import type { ResultItemType } from '@/schemas/apiSchema' export function useMediaAssetActions() { const toast = useToast() const dialogStore = useDialogStore() const mediaContext = inject(MediaAssetKey, null) + const { copyToClipboard } = useCopyToClipboard() + const workflowStore = useWorkflowStore() + const workflowService = useWorkflowService() + const litegraphService = useLitegraphService() + const nodeDefStore = useNodeDefStore() + const settingStore = useSettingStore() + const dialogService = useDialogService() const downloadAsset = () => { const asset = mediaContext?.asset.value @@ -195,10 +214,6 @@ export function useMediaAssetActions() { } } - const playAsset = (assetId: string) => { - console.log('Playing asset:', assetId) - } - const copyJobId = async () => { const asset = mediaContext?.asset.value if (!asset) return @@ -217,38 +232,252 @@ export function useMediaAssetActions() { return } + await copyToClipboard(promptId) + } + + /** + * Helper function to get workflow data from asset + * Tries to get workflow from metadata first, then falls back to extracting from file + */ + const getWorkflowFromAsset = async ( + asset: AssetItem + ): Promise => { + // First try to get workflow from metadata (for output assets) + const metadata = getOutputAssetMetadata(asset.user_metadata) + if (metadata?.workflow) { + return metadata.workflow as ComfyWorkflowJSON + } + + // For input assets or assets with embedded workflow, try to extract from file + // Fetch the file and extract workflow metadata + try { + const assetType = asset.tags?.[0] || 'output' + const fileUrl = api.apiURL( + `/view?filename=${encodeURIComponent(asset.name)}&type=${assetType}` + ) + const response = await fetch(fileUrl) + if (!response.ok) { + return null + } + + const blob = await response.blob() + const file = new File([blob], asset.name, { type: blob.type }) + + const workflowData = await getWorkflowDataFromFile(file) + if (workflowData?.workflow) { + // Handle both string and object workflow data + if (typeof workflowData.workflow === 'string') { + return JSON.parse(workflowData.workflow) as ComfyWorkflowJSON + } + return workflowData.workflow as ComfyWorkflowJSON + } + } catch (error) { + console.error('Failed to extract workflow from file:', error) + } + + return null + } + + /** + * Add a loader node to the current workflow for this asset + * Similar to useJobMenu's addOutputLoaderNode + */ + const addWorkflow = async () => { + const asset = mediaContext?.asset.value + if (!asset) return + + // Determine the appropriate loader node type based on file extension + const filename = asset.name.toLowerCase() + let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null + let widgetName: 'image' | 'file' | 'audio' | null = null + + if ( + filename.endsWith('.png') || + filename.endsWith('.jpg') || + filename.endsWith('.jpeg') || + filename.endsWith('.webp') || + filename.endsWith('.gif') + ) { + nodeType = 'LoadImage' + widgetName = 'image' + } else if ( + filename.endsWith('.mp4') || + filename.endsWith('.webm') || + filename.endsWith('.mov') + ) { + nodeType = 'LoadVideo' + widgetName = 'file' + } else if ( + filename.endsWith('.mp3') || + filename.endsWith('.wav') || + filename.endsWith('.ogg') || + filename.endsWith('.flac') + ) { + nodeType = 'LoadAudio' + widgetName = 'audio' + } + + if (!nodeType || !widgetName) { + toast.add({ + severity: 'warn', + summary: t('g.warning'), + detail: 'Unsupported file type for loader node', + life: 2000 + }) + return + } + + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) { + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: `Node type ${nodeType} not found`, + life: 3000 + }) + return + } + + const node = litegraphService.addNodeOnGraph(nodeDef, { + pos: litegraphService.getCanvasCenter() + }) + + if (!node) { + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: 'Failed to create node', + life: 3000 + }) + return + } + + // Get metadata to construct the annotated path + const metadata = getOutputAssetMetadata(asset.user_metadata) + const assetType = asset.tags?.[0] || 'input' + + const isResultItemType = (v: string | undefined): v is ResultItemType => + v === 'input' || v === 'output' || v === 'temp' + + // Create annotated path for the asset + const annotated = createAnnotatedPath( + { + filename: asset.name, + subfolder: metadata?.subfolder || '', + type: isResultItemType(assetType) ? assetType : undefined + }, + { + rootFolder: isResultItemType(assetType) ? assetType : undefined + } + ) + + const widget = node.widgets?.find((w) => w.name === widgetName) + if (widget) { + widget.value = annotated + widget.callback?.(annotated) + } + node.graph?.setDirtyCanvas(true, true) + + toast.add({ + severity: 'success', + summary: t('g.success'), + detail: `${nodeType} node added to workflow`, + life: 2000 + }) + } + + /** + * Open the workflow from this asset in a new tab + * Similar to useJobMenu's openJobWorkflow + */ + const openWorkflow = async () => { + const asset = mediaContext?.asset.value + if (!asset) return + + const workflow = await getWorkflowFromAsset(asset) + if (!workflow) { + toast.add({ + severity: 'warn', + summary: t('g.warning'), + detail: 'No workflow data found in this asset', + life: 2000 + }) + return + } + try { - await navigator.clipboard.writeText(promptId) + const filename = `${asset.name.replace(/\.[^/.]+$/, '')}.json` + const temp = workflowStore.createTemporary(filename, workflow) + await workflowService.openWorkflow(temp) + toast.add({ severity: 'success', summary: t('g.success'), - detail: t('mediaAsset.jobIdToast.jobIdCopied'), + detail: 'Workflow opened in new tab', life: 2000 }) } catch (error) { toast.add({ severity: 'error', summary: t('g.error'), - detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'), + detail: + error instanceof Error ? error.message : 'Failed to open workflow', life: 3000 }) } } - const addWorkflow = (assetId: string) => { - console.log('Adding asset to workflow:', assetId) - } + /** + * Export the workflow from this asset as a JSON file + * Similar to useJobMenu's exportJobWorkflow + */ + const exportWorkflow = async () => { + const asset = mediaContext?.asset.value + if (!asset) return - const openWorkflow = (assetId: string) => { - console.log('Opening workflow for asset:', assetId) - } + const workflow = await getWorkflowFromAsset(asset) + if (!workflow) { + toast.add({ + severity: 'warn', + summary: t('g.warning'), + detail: 'No workflow data found in this asset', + life: 2000 + }) + return + } - const exportWorkflow = (assetId: string) => { - console.log('Exporting workflow for asset:', assetId) - } + try { + let filename = `${asset.name.replace(/\.[^/.]+$/, '')}.json` + + if (settingStore.get('Comfy.PromptFilename')) { + const input = await dialogService.prompt({ + title: t('workflowService.exportWorkflow'), + message: t('workflowService.enterFilename') + ':', + defaultValue: filename + }) + if (!input) return + filename = appendJsonExt(input) + } + + const json = JSON.stringify(workflow, null, 2) + const blob = new Blob([json], { type: 'application/json' }) + downloadBlob(filename, blob) - const openMoreOutputs = (assetId: string) => { - console.log('Opening more outputs for asset:', assetId) + toast.add({ + severity: 'success', + summary: t('g.success'), + detail: 'Workflow exported successfully', + life: 2000 + }) + } catch (error) { + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: + error instanceof Error ? error.message : 'Failed to export workflow', + life: 3000 + }) + } } /** @@ -330,11 +559,9 @@ export function useMediaAssetActions() { confirmDelete, deleteAsset, deleteMultipleAssets, - playAsset, copyJobId, addWorkflow, openWorkflow, - exportWorkflow, - openMoreOutputs + exportWorkflow } } From c7dd5e9df10e688d9052af54f90302107b1cca95 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Fri, 14 Nov 2025 14:48:21 +0900 Subject: [PATCH 2/6] [feat] Implement media asset workflow actions and refactor duplications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented 4 missing workflow features for media assets: - copyJobId: Fixed to properly extract promptId from metadata - addWorkflow: Add loader nodes (LoadImage/LoadVideo/LoadAudio) to canvas - openWorkflow: Open workflow from asset metadata/embedded PNG in new tab - exportWorkflow: Export workflow as JSON file Created shared utilities to eliminate code duplication: - assetTypeUtil.ts: Extract asset type with fallback (replaced 6 duplications) - assetUrlUtil.ts: Construct asset URLs (replaced 3 duplications) - workflowActionsService.ts: Shared workflow export/open operations - workflowExtractionUtil.ts: Extract workflows from jobs/assets - loaderNodeUtil.ts: Detect loader node types from filenames Refactored existing code: - Used formatUtil.getMediaTypeFromFilename() in loaderNodeUtil - Extracted deleteAssetApi() helper to reduce deletion logic duplication - Moved isResultItemType type guard to shared typeGuardUtil.ts - Added 9 i18n strings for proper localization - Added @comfyorg/shared-frontend-utils dependency Input assets now support workflow features where applicable: - All media files (JPEG/PNG/MP4/etc) can be added to current workflow - PNG/WEBP/FLAC files with embedded metadata support open/export workflow 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- package.json | 1 + pnpm-lock.yaml | 3 + src/locales/en/main.json | 11 +- .../assets/components/MediaAssetMoreMenu.vue | 45 ++- .../composables/useMediaAssetActions.ts | 322 ++++++------------ src/platform/assets/utils/assetTypeUtil.ts | 24 ++ src/platform/assets/utils/assetUrlUtil.ts | 29 ++ .../core/services/workflowActionsService.ts | 123 +++++++ .../workflow/utils/workflowExtractionUtil.ts | 93 +++++ src/utils/loaderNodeUtil.ts | 38 +++ src/utils/typeGuardUtil.ts | 11 + 11 files changed, 479 insertions(+), 221 deletions(-) create mode 100644 src/platform/assets/utils/assetTypeUtil.ts create mode 100644 src/platform/assets/utils/assetUrlUtil.ts create mode 100644 src/platform/workflow/core/services/workflowActionsService.ts create mode 100644 src/platform/workflow/utils/workflowExtractionUtil.ts create mode 100644 src/utils/loaderNodeUtil.ts diff --git a/package.json b/package.json index cdc8aadd99..5b9f4133d7 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "@comfyorg/comfyui-electron-types": "0.4.73-0", "@comfyorg/design-system": "workspace:*", "@comfyorg/registry-types": "workspace:*", + "@comfyorg/shared-frontend-utils": "workspace:*", "@comfyorg/tailwind-utils": "workspace:*", "@iconify/json": "catalog:", "@primeuix/forms": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fc27afcdd..d65a29266a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: '@comfyorg/registry-types': specifier: workspace:* version: link:packages/registry-types + '@comfyorg/shared-frontend-utils': + specifier: workspace:* + version: link:packages/shared-frontend-utils '@comfyorg/tailwind-utils': specifier: workspace:* version: link:packages/tailwind-utils diff --git a/src/locales/en/main.json b/src/locales/en/main.json index b4d1ec73bb..212ccb2ef8 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2037,7 +2037,16 @@ "downloadsStarted": "Started downloading {count} file(s)", "assetsDeletedSuccessfully": "{count} asset(s) deleted successfully", "failedToDeleteAssets": "Failed to delete selected assets" - } + }, + "noJobIdFound": "No job ID found for this asset", + "unsupportedFileType": "Unsupported file type for loader node", + "nodeTypeNotFound": "Node type {nodeType} not found", + "failedToCreateNode": "Failed to create node", + "nodeAddedToWorkflow": "{nodeType} node added to workflow", + "noWorkflowDataFound": "No workflow data found in this asset", + "workflowOpenedInNewTab": "Workflow opened in new tab", + "failedToExportWorkflow": "Failed to export workflow", + "workflowExportedSuccessfully": "Workflow exported successfully" }, "actionbar": { "dockToTop": "Dock to top", diff --git a/src/platform/assets/components/MediaAssetMoreMenu.vue b/src/platform/assets/components/MediaAssetMoreMenu.vue index 5a43dd8613..3c236ed7ee 100644 --- a/src/platform/assets/components/MediaAssetMoreMenu.vue +++ b/src/platform/assets/components/MediaAssetMoreMenu.vue @@ -1,5 +1,8 @@