feat: add bulk actions for workflow operations in media assets (#7992)

## 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

<img width="1927" height="921" alt="스크린샷 2026-01-13 오후 12 54 52"
src="https://github.com/user-attachments/assets/6f079232-1b24-4f02-810f-6e396916bb71"
/>


┆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)
This commit is contained in:
Jin Yi
2026-01-14 13:24:13 +09:00
committed by GitHub
parent 25afd39d2b
commit 6382b1e099
4 changed files with 270 additions and 5 deletions

View File

@@ -204,6 +204,9 @@
@asset-deleted="refreshAssets" @asset-deleted="refreshAssets"
@bulk-download="handleBulkDownload" @bulk-download="handleBulkDownload"
@bulk-delete="handleBulkDelete" @bulk-delete="handleBulkDelete"
@bulk-add-to-workflow="handleBulkAddToWorkflow"
@bulk-open-workflow="handleBulkOpenWorkflow"
@bulk-export-workflow="handleBulkExportWorkflow"
/> />
</template> </template>
@@ -321,7 +324,13 @@ const {
deactivate: deactivateSelection deactivate: deactivateSelection
} = useAssetSelection() } = useAssetSelection()
const { downloadMultipleAssets, deleteMultipleAssets } = useMediaAssetActions() const {
downloadMultipleAssets,
deleteMultipleAssets,
addMultipleToWorkflow,
openMultipleWorkflows,
exportMultipleWorkflows
} = useMediaAssetActions()
// Footer responsive behavior // Footer responsive behavior
const footerRef = ref<HTMLElement | null>(null) const footerRef = ref<HTMLElement | null>(null)
@@ -607,6 +616,21 @@ const handleBulkDelete = async (assets: AssetItem[]) => {
clearSelection() 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 () => { const handleClearQueue = async () => {
await commandStore.execute('Comfy.ClearPendingTasks') await commandStore.execute('Comfy.ClearPendingTasks')
} }

View File

@@ -2414,7 +2414,7 @@
"zoom": "Zoom in", "zoom": "Zoom in",
"moreOptions": "More options", "moreOptions": "More options",
"seeMoreOutputs": "See more outputs", "seeMoreOutputs": "See more outputs",
"addToWorkflow": "Add to current workflow", "insertAsNodeInWorkflow": "Insert as node in workflow",
"download": "Download", "download": "Download",
"openWorkflow": "Open as workflow in new tab", "openWorkflow": "Open as workflow in new tab",
"exportWorkflow": "Export workflow", "exportWorkflow": "Export workflow",
@@ -2435,11 +2435,23 @@
"downloadSelectedAll": "Download all", "downloadSelectedAll": "Download all",
"deleteSelected": "Delete", "deleteSelected": "Delete",
"deleteSelectedAll": "Delete all", "deleteSelectedAll": "Delete all",
"insertAllAssetsAsNodes": "Insert all assets as nodes",
"openWorkflowAll": "Open all workflows",
"exportWorkflowAll": "Export all workflows",
"downloadStarted": "Downloading {count} files...", "downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)", "downloadsStarted": "Started downloading {count} file(s)",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully", "assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets", "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", "noJobIdFound": "No job ID found for this asset",
"unsupportedFileType": "Unsupported file type for loader node", "unsupportedFileType": "Unsupported file type for loader node",

View File

@@ -67,6 +67,9 @@ const emit = defineEmits<{
'bulk-download': [assets: AssetItem[]] 'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]] 'bulk-delete': [assets: AssetItem[]]
hide: [] hide: []
'bulk-add-to-workflow': [assets: AssetItem[]]
'bulk-open-workflow': [assets: AssetItem[]]
'bulk-export-workflow': [assets: AssetItem[]]
}>() }>()
const contextMenu = ref<InstanceType<typeof ContextMenu>>() const contextMenu = ref<InstanceType<typeof ContextMenu>>()
@@ -142,6 +145,27 @@ const contextMenuItems = computed<MenuItem[]>(() => {
disabled: true 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 // Bulk Download
items.push({ items.push({
label: t('mediaAsset.selection.downloadSelectedAll'), label: t('mediaAsset.selection.downloadSelectedAll'),
@@ -175,7 +199,7 @@ const contextMenuItems = computed<MenuItem[]>(() => {
// Add to workflow (conditional) // Add to workflow (conditional)
if (showAddToWorkflow.value) { if (showAddToWorkflow.value) {
items.push({ items.push({
label: t('mediaAsset.actions.addToWorkflow'), label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
icon: 'icon-[comfy--node]', icon: 'icon-[comfy--node]',
command: () => actions.addWorkflow(asset) command: () => actions.addWorkflow(asset)
}) })

View File

@@ -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 * Delete multiple assets with confirmation dialog
* @param assets Array of assets to delete * @param assets Array of assets to delete
@@ -482,7 +684,10 @@ export function useMediaAssetActions() {
deleteMultipleAssets, deleteMultipleAssets,
copyJobId, copyJobId,
addWorkflow, addWorkflow,
addMultipleToWorkflow,
openWorkflow, openWorkflow,
exportWorkflow openMultipleWorkflows,
exportWorkflow,
exportMultipleWorkflows
} }
} }