Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/components/sidebar/tabs/AssetsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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')
}
Expand Down
16 changes: 14 additions & 2 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -2412,7 +2412,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",
Expand All @@ -2433,11 +2433,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",
Expand Down
26 changes: 25 additions & 1 deletion src/platform/assets/components/MediaAssetContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>>()
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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)
})
Expand Down
207 changes: 206 additions & 1 deletion src/platform/assets/composables/useMediaAssetActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure that the "Add to workflow action" for multiple media assets is meant to add a loader node with the asset? For some reason I was under the impression that it was supposed to add the workflow that generated that asset into the current workflow (similar to the behavior when clicking right-clicking a workflow and doing "insert workflow")?

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
Expand Down Expand Up @@ -482,7 +684,10 @@ export function useMediaAssetActions() {
deleteMultipleAssets,
copyJobId,
addWorkflow,
addMultipleToWorkflow,
openWorkflow,
exportWorkflow
openMultipleWorkflows,
exportWorkflow,
exportMultipleWorkflows
}
}