From 6382b1e099286b335c3cdbf4f576fcd2c1f9a04f Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Wed, 14 Jan 2026 13:24:13 +0900 Subject: [PATCH] feat: add bulk actions for workflow operations in media assets (#7992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add bulk action support for Add to Workflow, Open Workflow, and Export Workflow when multiple assets are selected. ## Changes - **What**: Bulk operations for Add to Workflow, Open/Export Workflow in context menu ## Review Focus - Node positioning: Multiple nodes created at same canvas center position (may overlap) - Context menu item ordering without separators 스크린샷 2026-01-13 오후 12 54 52 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7992-feat-add-bulk-actions-for-workflow-operations-in-media-assets-2e76d73d365081aa90c6fdb5039c9a3e) by [Unito](https://www.unito.io) --- .../sidebar/tabs/AssetsSidebarTab.vue | 26 ++- src/locales/en/main.json | 16 +- .../components/MediaAssetContextMenu.vue | 26 ++- .../composables/useMediaAssetActions.ts | 207 +++++++++++++++++- 4 files changed, 270 insertions(+), 5 deletions(-) diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 61f6a205e..8841d5553 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -204,6 +204,9 @@ @asset-deleted="refreshAssets" @bulk-download="handleBulkDownload" @bulk-delete="handleBulkDelete" + @bulk-add-to-workflow="handleBulkAddToWorkflow" + @bulk-open-workflow="handleBulkOpenWorkflow" + @bulk-export-workflow="handleBulkExportWorkflow" /> @@ -321,7 +324,13 @@ const { deactivate: deactivateSelection } = useAssetSelection() -const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions() +const { + downloadMultipleAssets, + deleteMultipleAssets, + addMultipleToWorkflow, + openMultipleWorkflows, + exportMultipleWorkflows +} = useMediaAssetActions() // Footer responsive behavior const footerRef = ref(null) @@ -607,6 +616,21 @@ const handleBulkDelete = async (assets: AssetItem[]) => { clearSelection() } +const handleBulkAddToWorkflow = async (assets: AssetItem[]) => { + await addMultipleToWorkflow(assets) + clearSelection() +} + +const handleBulkOpenWorkflow = async (assets: AssetItem[]) => { + await openMultipleWorkflows(assets) + clearSelection() +} + +const handleBulkExportWorkflow = async (assets: AssetItem[]) => { + await exportMultipleWorkflows(assets) + clearSelection() +} + const handleClearQueue = async () => { await commandStore.execute('Comfy.ClearPendingTasks') } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index ed1c78168..555ff07fb 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -2414,7 +2414,7 @@ "zoom": "Zoom in", "moreOptions": "More options", "seeMoreOutputs": "See more outputs", - "addToWorkflow": "Add to current workflow", + "insertAsNodeInWorkflow": "Insert as node in workflow", "download": "Download", "openWorkflow": "Open as workflow in new tab", "exportWorkflow": "Export workflow", @@ -2435,11 +2435,23 @@ "downloadSelectedAll": "Download all", "deleteSelected": "Delete", "deleteSelectedAll": "Delete all", + "insertAllAssetsAsNodes": "Insert all assets as nodes", + "openWorkflowAll": "Open all workflows", + "exportWorkflowAll": "Export all workflows", "downloadStarted": "Downloading {count} files...", "downloadsStarted": "Started downloading {count} file(s)", "assetsDeletedSuccessfully": "{count} asset(s) deleted successfully", "failedToDeleteAssets": "Failed to delete selected assets", - "partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed" + "partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed", + "nodesAddedToWorkflow": "{count} node(s) added to workflow", + "failedToAddNodes": "Failed to add nodes to workflow", + "partialAddNodesSuccess": "{succeeded} added successfully, {failed} failed", + "workflowsOpened": "{count} workflow(s) opened in new tabs", + "noWorkflowsFound": "No workflow data found in selected assets", + "partialWorkflowsOpened": "{succeeded} workflow(s) opened, {failed} failed", + "workflowsExported": "{count} workflow(s) exported successfully", + "noWorkflowsToExport": "No workflow data found to export", + "partialWorkflowsExported": "{succeeded} exported successfully, {failed} failed" }, "noJobIdFound": "No job ID found for this asset", "unsupportedFileType": "Unsupported file type for loader node", diff --git a/src/platform/assets/components/MediaAssetContextMenu.vue b/src/platform/assets/components/MediaAssetContextMenu.vue index fe49c2ca1..0b54ce33b 100644 --- a/src/platform/assets/components/MediaAssetContextMenu.vue +++ b/src/platform/assets/components/MediaAssetContextMenu.vue @@ -67,6 +67,9 @@ const emit = defineEmits<{ 'bulk-download': [assets: AssetItem[]] 'bulk-delete': [assets: AssetItem[]] hide: [] + 'bulk-add-to-workflow': [assets: AssetItem[]] + 'bulk-open-workflow': [assets: AssetItem[]] + 'bulk-export-workflow': [assets: AssetItem[]] }>() const contextMenu = ref>() @@ -142,6 +145,27 @@ const contextMenuItems = computed(() => { disabled: true }) + // Bulk Add to Workflow + items.push({ + label: t('mediaAsset.selection.insertAllAssetsAsNodes'), + icon: 'icon-[comfy--node]', + command: () => emit('bulk-add-to-workflow', selectedAssets) + }) + + // Bulk Open Workflow + items.push({ + label: t('mediaAsset.selection.openWorkflowAll'), + icon: 'icon-[comfy--workflow]', + command: () => emit('bulk-open-workflow', selectedAssets) + }) + + // Bulk Export Workflow + items.push({ + label: t('mediaAsset.selection.exportWorkflowAll'), + icon: 'icon-[lucide--file-output]', + command: () => emit('bulk-export-workflow', selectedAssets) + }) + // Bulk Download items.push({ label: t('mediaAsset.selection.downloadSelectedAll'), @@ -175,7 +199,7 @@ const contextMenuItems = computed(() => { // Add to workflow (conditional) if (showAddToWorkflow.value) { items.push({ - label: t('mediaAsset.actions.addToWorkflow'), + label: t('mediaAsset.actions.insertAsNodeInWorkflow'), icon: 'icon-[comfy--node]', command: () => actions.addWorkflow(asset) }) diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index ec17364d1..395fae23a 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -365,6 +365,208 @@ export function useMediaAssetActions() { } } + /** + * Add multiple assets to the current workflow + * Creates loader nodes for each asset + */ + const addMultipleToWorkflow = async (assets: AssetItem[]) => { + if (!assets || assets.length === 0) return + + const NODE_OFFSET = 50 + let nodeIndex = 0 + let succeeded = 0 + let failed = 0 + + for (const asset of assets) { + const { nodeType, widgetName } = detectNodeTypeFromFilename(asset.name) + + if (!nodeType || !widgetName) { + failed++ + continue + } + + const nodeDef = nodeDefStore.nodeDefsByName[nodeType] + if (!nodeDef) { + failed++ + continue + } + + const center = litegraphService.getCanvasCenter() + const node = litegraphService.addNodeOnGraph(nodeDef, { + pos: [ + center[0] + nodeIndex * NODE_OFFSET, + center[1] + nodeIndex * NODE_OFFSET + ] + }) + + if (!node) { + failed++ + continue + } + + const metadata = getOutputAssetMetadata(asset.user_metadata) + const assetType = getAssetType(asset, 'input') + + 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) + succeeded++ + nodeIndex++ + } + + if (failed === 0) { + toast.add({ + severity: 'success', + summary: t('g.success'), + detail: t('mediaAsset.selection.nodesAddedToWorkflow', { + count: succeeded + }), + life: 2000 + }) + } else if (succeeded === 0) { + toast.add({ + severity: 'error', + summary: t('g.error'), + detail: t('mediaAsset.selection.failedToAddNodes'), + life: 3000 + }) + } else { + toast.add({ + severity: 'warn', + summary: t('g.warning'), + detail: t('mediaAsset.selection.partialAddNodesSuccess', { + succeeded, + failed + }), + life: 3000 + }) + } + } + + /** + * Open workflows from multiple assets in new tabs + */ + const openMultipleWorkflows = async (assets: AssetItem[]) => { + if (!assets || assets.length === 0) return + + let succeeded = 0 + let failed = 0 + + for (const asset of assets) { + try { + const { workflow, filename } = await extractWorkflowFromAsset(asset) + const result = await workflowActions.openWorkflowAction( + workflow, + filename + ) + + if (result.success) { + succeeded++ + } else { + failed++ + } + } catch { + failed++ + } + } + + if (failed === 0) { + toast.add({ + severity: 'success', + summary: t('g.success'), + detail: t('mediaAsset.selection.workflowsOpened', { count: succeeded }), + life: 2000 + }) + } else if (succeeded === 0) { + toast.add({ + severity: 'warn', + summary: t('g.warning'), + detail: t('mediaAsset.selection.noWorkflowsFound'), + life: 3000 + }) + } else { + toast.add({ + severity: 'warn', + summary: t('g.warning'), + detail: t('mediaAsset.selection.partialWorkflowsOpened', { + succeeded, + failed + }), + life: 3000 + }) + } + } + + /** + * Export workflows from multiple assets as JSON files + */ + const exportMultipleWorkflows = async (assets: AssetItem[]) => { + if (!assets || assets.length === 0) return + + let succeeded = 0 + let failed = 0 + + for (const asset of assets) { + try { + const { workflow, filename } = await extractWorkflowFromAsset(asset) + const result = await workflowActions.exportWorkflowAction( + workflow, + filename + ) + + if (result.success) { + succeeded++ + } else { + failed++ + } + } catch { + failed++ + } + } + + if (failed === 0) { + toast.add({ + severity: 'success', + summary: t('g.success'), + detail: t('mediaAsset.selection.workflowsExported', { + count: succeeded + }), + life: 2000 + }) + } else if (succeeded === 0) { + toast.add({ + severity: 'warn', + summary: t('g.warning'), + detail: t('mediaAsset.selection.noWorkflowsToExport'), + life: 3000 + }) + } else { + toast.add({ + severity: 'warn', + summary: t('g.warning'), + detail: t('mediaAsset.selection.partialWorkflowsExported', { + succeeded, + failed + }), + life: 3000 + }) + } + } + /** * Delete multiple assets with confirmation dialog * @param assets Array of assets to delete @@ -482,7 +684,10 @@ export function useMediaAssetActions() { deleteMultipleAssets, copyJobId, addWorkflow, + addMultipleToWorkflow, openWorkflow, - exportWorkflow + openMultipleWorkflows, + exportWorkflow, + exportMultipleWorkflows } }