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

@@ -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<InstanceType<typeof ContextMenu>>()
@@ -142,6 +145,27 @@ const contextMenuItems = computed<MenuItem[]>(() => {
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<MenuItem[]>(() => {
// 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)
})

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