mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
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:
@@ -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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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<HTMLElement | null>(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')
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user