mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[feat] Implement media asset workflow actions with shared utilities (#6696)
## Summary Implements 4 missing media asset workflow features and creates shared utilities to eliminate code duplication. ## Implemented Features ### 1. Copy Job ID ✅ - Properly extracts promptId using `getOutputAssetMetadata` - Uses `useCopyToClipboard` composable ### 2. Add to Current Workflow ✅ - Adds LoadImage/LoadVideo/LoadAudio nodes to canvas - Supports all media file types (JPEG, PNG, MP4, etc.) - Auto-detects appropriate node type using `detectNodeTypeFromFilename` utility ### 3. Open Workflow in New Tab ✅ - Extracts workflow from asset metadata or embedded PNG - Opens workflow in new tab ### 4. Export Workflow ✅ - Exports workflow as JSON file - Supports optional filename prompt ## Code Refactoring ### Created Shared Utilities: 1. **`assetTypeUtil.ts`** - `getAssetType()` function eliminates 6 instances of `asset.tags?.[0] || 'output'` 2. **`assetUrlUtil.ts`** - `getAssetUrl()` function consolidates 3 URL construction patterns 3. **`workflowActionsService.ts`** - Shared service for workflow export/open operations 4. **`workflowExtractionUtil.ts`** - Extract workflows from jobs/assets 5. **`loaderNodeUtil.ts`** - Detect loader node types from filenames ### Improvements to Existing Code: - Refactored to use `formatUtil.getMediaTypeFromFilename()` - Extracted `deleteAssetApi()` helper to reduce deletion logic duplication (~40 lines) - Moved `isResultItemType` type guard to shared `typeGuardUtil.ts` - Added 9 i18n strings for proper localization - Added `@comfyorg/shared-frontend-utils` dependency ## Input Assets Support Improved input assets to support workflow features where applicable: - ✅ All media files (JPEG/PNG/MP4, etc.) → "Add to current workflow" enabled - ✅ PNG/WEBP/FLAC with embedded metadata → "Open/Export workflow" enabled ## Impact - **~150+ lines** of duplicate code eliminated - **5 new utility files** created to improve code reusability - **11 files** changed, **483 insertions**, **234 deletions** ## Testing ✅ TypeScript typecheck passed ✅ ESLint passed ✅ Knip passed 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6696-feat-Implement-media-asset-workflow-actions-with-shared-utilities-2ab6d73d365081fb8ae9d71ce6e38589) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -131,6 +131,7 @@
|
||||
"@comfyorg/comfyui-electron-types": "0.4.73-0",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
"@comfyorg/tailwind-utils": "workspace:*",
|
||||
"@iconify/json": "catalog:",
|
||||
"@primeuix/forms": "catalog:",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -326,6 +326,9 @@ importers:
|
||||
'@comfyorg/registry-types':
|
||||
specifier: workspace:*
|
||||
version: link:packages/registry-types
|
||||
'@comfyorg/shared-frontend-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/shared-frontend-utils
|
||||
'@comfyorg/tailwind-utils':
|
||||
specifier: workspace:*
|
||||
version: link:packages/tailwind-utils
|
||||
|
||||
@@ -2036,8 +2036,18 @@
|
||||
"downloadStarted": "Downloading {count} files...",
|
||||
"downloadsStarted": "Started downloading {count} file(s)",
|
||||
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
|
||||
"failedToDeleteAssets": "Failed to delete selected assets"
|
||||
}
|
||||
"failedToDeleteAssets": "Failed to delete selected assets",
|
||||
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed"
|
||||
},
|
||||
"noJobIdFound": "No job ID found for this asset",
|
||||
"unsupportedFileType": "Unsupported file type for loader node",
|
||||
"nodeTypeNotFound": "Node type {nodeType} not found",
|
||||
"failedToCreateNode": "Failed to create node",
|
||||
"nodeAddedToWorkflow": "{nodeType} node added to workflow",
|
||||
"noWorkflowDataFound": "No workflow data found in this asset",
|
||||
"workflowOpenedInNewTab": "Workflow opened in new tab",
|
||||
"failedToExportWorkflow": "Failed to export workflow",
|
||||
"workflowExportedSuccessfully": "Workflow exported successfully"
|
||||
},
|
||||
"actionbar": {
|
||||
"dockToTop": "Dock to top",
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
:context="{ type: assetType }"
|
||||
@view="handleZoomClick"
|
||||
@download="actions.downloadAsset()"
|
||||
@play="actions.playAsset(asset.id)"
|
||||
@video-playing-state-changed="isVideoPlaying = $event"
|
||||
@video-controls-changed="showVideoControls = $event"
|
||||
@image-loaded="handleImageLoaded"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<!-- TODO: 3D assets currently excluded from inspection.
|
||||
When 3D loader nodes are implemented, update detectNodeTypeFromFilename
|
||||
to return appropriate node type for .gltf, .glb files and remove this exclusion -->
|
||||
<IconTextButton
|
||||
v-if="asset?.kind !== '3D'"
|
||||
type="transparent"
|
||||
@@ -12,7 +15,7 @@
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
v-if="showAddToWorkflow"
|
||||
type="transparent"
|
||||
label="Add to current workflow"
|
||||
@click="handleAddToWorkflow"
|
||||
@@ -28,10 +31,10 @@
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider v-if="showWorkflowOptions" />
|
||||
<MediaAssetButtonDivider v-if="showAddToWorkflow || showWorkflowActions" />
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
v-if="showWorkflowActions"
|
||||
type="transparent"
|
||||
label="Open as workflow in new tab"
|
||||
@click="handleOpenWorkflow"
|
||||
@@ -42,7 +45,7 @@
|
||||
</IconTextButton>
|
||||
|
||||
<IconTextButton
|
||||
v-if="showWorkflowOptions"
|
||||
v-if="showWorkflowActions"
|
||||
type="transparent"
|
||||
label="Export workflow"
|
||||
@click="handleExportWorkflow"
|
||||
@@ -52,7 +55,7 @@
|
||||
</template>
|
||||
</IconTextButton>
|
||||
|
||||
<MediaAssetButtonDivider v-if="showWorkflowOptions && showCopyJobId" />
|
||||
<MediaAssetButtonDivider v-if="showWorkflowActions && showCopyJobId" />
|
||||
|
||||
<IconTextButton
|
||||
v-if="showCopyJobId"
|
||||
@@ -85,6 +88,8 @@ import { computed, inject } from 'vue'
|
||||
|
||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
@@ -107,7 +112,35 @@ const assetType = computed(() => {
|
||||
return asset.value?.tags?.[0] || context.value?.type || 'output'
|
||||
})
|
||||
|
||||
const showWorkflowOptions = computed(() => assetType.value === 'output')
|
||||
// Show "Add to current workflow" for all media files (images, videos, audio)
|
||||
// This works for any file type that has a corresponding loader node
|
||||
const showAddToWorkflow = computed(() => {
|
||||
// Output assets can always be added
|
||||
if (assetType.value === 'output') return true
|
||||
|
||||
// Input assets: check if file type is supported by loader nodes
|
||||
// Use the same utility as the actual addWorkflow function for consistency
|
||||
if (assetType.value === 'input' && asset.value?.name) {
|
||||
const { nodeType } = detectNodeTypeFromFilename(asset.value.name)
|
||||
return nodeType !== null
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Show "Open/Export workflow" only for files with workflow metadata
|
||||
// This is more restrictive - only PNG, WEBP, FLAC support embedded workflows
|
||||
const showWorkflowActions = computed(() => {
|
||||
// Output assets always have workflow metadata
|
||||
if (assetType.value === 'output') return true
|
||||
|
||||
// Input assets: only formats that support workflow metadata
|
||||
if (assetType.value === 'input' && asset.value?.name) {
|
||||
return supportsWorkflowMetadata(asset.value.name)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Only show Copy Job ID for output assets (not for imported/input assets)
|
||||
const showCopyJobId = computed(() => {
|
||||
@@ -129,7 +162,7 @@ const handleInspect = () => {
|
||||
|
||||
const handleAddToWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.addWorkflow(asset.value.id)
|
||||
actions.addWorkflow()
|
||||
}
|
||||
close()
|
||||
}
|
||||
@@ -143,14 +176,14 @@ const handleDownload = () => {
|
||||
|
||||
const handleOpenWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.openWorkflow(asset.value.id)
|
||||
actions.openWorkflow()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
const handleExportWorkflow = () => {
|
||||
if (asset.value) {
|
||||
actions.exportWorkflow(asset.value.id)
|
||||
actions.exportWorkflow()
|
||||
}
|
||||
close()
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
/* eslint-disable no-console */
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { inject } from 'vue'
|
||||
|
||||
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { t } from '@/i18n'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useWorkflowActionsService } from '@/platform/workflow/core/services/workflowActionsService'
|
||||
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { getAssetType } from '../utils/assetTypeUtil'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { isResultItemType } from '@/utils/typeGuardUtil'
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
@@ -19,6 +28,35 @@ export function useMediaAssetActions() {
|
||||
const toast = useToast()
|
||||
const dialogStore = useDialogStore()
|
||||
const mediaContext = inject(MediaAssetKey, null)
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const workflowActions = useWorkflowActionsService()
|
||||
const litegraphService = useLitegraphService()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
/**
|
||||
* Internal helper to perform the API deletion for a single asset
|
||||
* Handles both output assets (via history API) and input assets (via asset service)
|
||||
* @throws Error if deletion fails or is not allowed
|
||||
*/
|
||||
const deleteAssetApi = async (
|
||||
asset: AssetItem,
|
||||
assetType: string
|
||||
): Promise<void> => {
|
||||
if (assetType === 'output') {
|
||||
const promptId =
|
||||
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
|
||||
if (!promptId) {
|
||||
throw new Error('Unable to extract prompt ID from asset')
|
||||
}
|
||||
await api.deleteItem('history', promptId)
|
||||
} else {
|
||||
// Input assets can only be deleted in cloud environment
|
||||
if (!isCloud) {
|
||||
throw new Error(t('mediaAsset.deletingImportedFilesCloudOnly'))
|
||||
}
|
||||
await assetService.deleteAsset(asset.id)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadAsset = () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
@@ -30,13 +68,10 @@ export function useMediaAssetActions() {
|
||||
|
||||
// In cloud, use preview_url directly (from cloud storage)
|
||||
// In OSS/localhost, use the /view endpoint
|
||||
if (isCloud && asset.src) {
|
||||
downloadUrl = asset.src
|
||||
if (isCloud && asset.preview_url) {
|
||||
downloadUrl = asset.preview_url
|
||||
} else {
|
||||
const assetType = asset.tags?.[0] || 'output'
|
||||
downloadUrl = api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
|
||||
)
|
||||
downloadUrl = getAssetUrl(asset)
|
||||
}
|
||||
|
||||
downloadFile(downloadUrl, filename)
|
||||
@@ -74,10 +109,7 @@ export function useMediaAssetActions() {
|
||||
if (isCloud && asset.preview_url) {
|
||||
downloadUrl = asset.preview_url
|
||||
} else {
|
||||
const assetType = asset.tags?.[0] || 'output'
|
||||
downloadUrl = api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(filename)}&type=${assetType}`
|
||||
)
|
||||
downloadUrl = getAssetUrl(asset)
|
||||
}
|
||||
downloadFile(downloadUrl, filename)
|
||||
})
|
||||
@@ -107,7 +139,7 @@ export function useMediaAssetActions() {
|
||||
* @returns true if the asset was deleted, false otherwise
|
||||
*/
|
||||
const confirmDelete = async (asset: AssetItem): Promise<boolean> => {
|
||||
const assetType = asset.tags?.[0] || 'output'
|
||||
const assetType = getAssetType(asset)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
dialogStore.showDialog({
|
||||
@@ -134,121 +166,200 @@ export function useMediaAssetActions() {
|
||||
const assetsStore = useAssetsStore()
|
||||
|
||||
try {
|
||||
// Perform the deletion
|
||||
await deleteAssetApi(asset, assetType)
|
||||
|
||||
// Update the appropriate store based on asset type
|
||||
if (assetType === 'output') {
|
||||
// For output files, delete from history
|
||||
const promptId =
|
||||
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
|
||||
if (!promptId) {
|
||||
throw new Error('Unable to extract prompt ID from asset')
|
||||
}
|
||||
|
||||
await api.deleteItem('history', promptId)
|
||||
|
||||
// Update history assets in store after deletion
|
||||
await assetsStore.updateHistory()
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.assetDeletedSuccessfully'),
|
||||
life: 2000
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
// For input files, only allow deletion in cloud environment
|
||||
if (!isCloud) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.deletingImportedFilesCloudOnly'),
|
||||
life: 3000
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// In cloud environment, use the assets API to delete
|
||||
await assetService.deleteAsset(asset.id)
|
||||
|
||||
// Update input assets in store after deletion
|
||||
await assetsStore.updateInputs()
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.assetDeletedSuccessfully'),
|
||||
life: 2000
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.assetDeletedSuccessfully'),
|
||||
life: 2000
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to delete asset:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : ''
|
||||
const isCloudWarning = errorMessage.includes('Cloud')
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('mediaAsset.failedToDeleteAsset'),
|
||||
severity: isCloudWarning ? 'warn' : 'error',
|
||||
summary: isCloudWarning ? t('g.warning') : t('g.error'),
|
||||
detail: errorMessage || t('mediaAsset.failedToDeleteAsset'),
|
||||
life: 3000
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const playAsset = (assetId: string) => {
|
||||
console.log('Playing asset:', assetId)
|
||||
}
|
||||
|
||||
const copyJobId = async () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
if (!asset) return
|
||||
|
||||
// Get promptId from metadata instead of parsing the ID string
|
||||
// Try asset.id first (OSS), then fall back to metadata (Cloud)
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const promptId = metadata?.promptId
|
||||
const promptId = asset.id || metadata?.promptId
|
||||
|
||||
if (!promptId) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: 'No job ID found for this asset',
|
||||
detail: t('mediaAsset.noJobIdFound'),
|
||||
life: 2000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(promptId)
|
||||
await copyToClipboard(promptId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a loader node to the current workflow for this asset
|
||||
* Uses shared utility to detect appropriate node type based on file extension
|
||||
*/
|
||||
const addWorkflow = async () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
if (!asset) return
|
||||
|
||||
// Detect node type using shared utility
|
||||
const { nodeType, widgetName } = detectNodeTypeFromFilename(asset.name)
|
||||
|
||||
if (!nodeType || !widgetName) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopied'),
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.unsupportedFileType'),
|
||||
life: 2000
|
||||
})
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
||||
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
|
||||
if (!nodeDef) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
|
||||
detail: t('mediaAsset.nodeTypeNotFound', { nodeType }),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: litegraphService.getCanvasCenter()
|
||||
})
|
||||
|
||||
if (!node) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.failedToCreateNode'),
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get metadata to construct the annotated path
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
const assetType = getAssetType(asset, 'input')
|
||||
|
||||
// Create annotated path for the asset
|
||||
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)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.nodeAddedToWorkflow', { nodeType }),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the workflow from this asset in a new tab
|
||||
* Uses shared workflow extraction and action service
|
||||
*/
|
||||
const openWorkflow = async () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
if (!asset) return
|
||||
|
||||
// Extract workflow using shared utility
|
||||
const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
||||
|
||||
// Use shared action service
|
||||
const result = await workflowActions.openWorkflowAction(workflow, filename)
|
||||
|
||||
if (!result.success) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: result.error || t('mediaAsset.noWorkflowDataFound'),
|
||||
life: 2000
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.workflowOpenedInNewTab'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const addWorkflow = (assetId: string) => {
|
||||
console.log('Adding asset to workflow:', assetId)
|
||||
}
|
||||
/**
|
||||
* Export the workflow from this asset as a JSON file
|
||||
* Uses shared workflow extraction and action service
|
||||
*/
|
||||
const exportWorkflow = async () => {
|
||||
const asset = mediaContext?.asset.value
|
||||
if (!asset) return
|
||||
|
||||
const openWorkflow = (assetId: string) => {
|
||||
console.log('Opening workflow for asset:', assetId)
|
||||
}
|
||||
// Extract workflow using shared utility
|
||||
const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
||||
|
||||
const exportWorkflow = (assetId: string) => {
|
||||
console.log('Exporting workflow for asset:', assetId)
|
||||
}
|
||||
// Use shared action service
|
||||
const result = await workflowActions.exportWorkflowAction(
|
||||
workflow,
|
||||
filename
|
||||
)
|
||||
|
||||
const openMoreOutputs = (assetId: string) => {
|
||||
console.log('Opening more outputs for asset:', assetId)
|
||||
if (!result.success) {
|
||||
const isNoWorkflow = result.error?.includes('No workflow')
|
||||
toast.add({
|
||||
severity: isNoWorkflow ? 'warn' : 'error',
|
||||
summary: isNoWorkflow ? t('g.warning') : t('g.error'),
|
||||
detail: result.error || t('mediaAsset.failedToExportWorkflow'),
|
||||
life: 3000
|
||||
})
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.workflowExportedSuccessfully'),
|
||||
life: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,37 +384,73 @@ export function useMediaAssetActions() {
|
||||
itemList: assets.map((asset) => asset.name),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
// Delete all assets
|
||||
await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
const assetType = asset.tags?.[0] || 'output'
|
||||
if (assetType === 'output') {
|
||||
const promptId =
|
||||
asset.id ||
|
||||
getOutputAssetMetadata(asset.user_metadata)?.promptId
|
||||
if (promptId) {
|
||||
await api.deleteItem('history', promptId)
|
||||
}
|
||||
} else if (isCloud) {
|
||||
await assetService.deleteAsset(asset.id)
|
||||
}
|
||||
})
|
||||
// Delete all assets using Promise.allSettled to track individual results
|
||||
const results = await Promise.allSettled(
|
||||
assets.map((asset) =>
|
||||
deleteAssetApi(asset, getAssetType(asset))
|
||||
)
|
||||
)
|
||||
|
||||
// Count successes and failures
|
||||
const succeeded = results.filter(
|
||||
(r) => r.status === 'fulfilled'
|
||||
).length
|
||||
const failed = results.filter((r) => r.status === 'rejected')
|
||||
|
||||
// Log failed deletions for debugging
|
||||
failed.forEach((result, index) => {
|
||||
console.warn(
|
||||
`Failed to delete asset ${assets[index].name}:`,
|
||||
result.reason
|
||||
)
|
||||
})
|
||||
|
||||
// Update stores after deletions
|
||||
await assetsStore.updateHistory()
|
||||
if (assets.some((a) => a.tags?.[0] === 'input')) {
|
||||
const hasOutputAssets = assets.some(
|
||||
(a) => getAssetType(a) === 'output'
|
||||
)
|
||||
const hasInputAssets = assets.some(
|
||||
(a) => getAssetType(a) === 'input'
|
||||
)
|
||||
|
||||
if (hasOutputAssets) {
|
||||
await assetsStore.updateHistory()
|
||||
}
|
||||
if (hasInputAssets) {
|
||||
await assetsStore.updateInputs()
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.assetsDeletedSuccessfully', {
|
||||
count: assets.length
|
||||
}),
|
||||
life: 2000
|
||||
})
|
||||
// Show appropriate feedback based on results
|
||||
if (failed.length === 0) {
|
||||
// All succeeded
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('g.success'),
|
||||
detail: t('mediaAsset.selection.assetsDeletedSuccessfully', {
|
||||
count: succeeded
|
||||
}),
|
||||
life: 2000
|
||||
})
|
||||
} else if (succeeded === 0) {
|
||||
// All failed
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('mediaAsset.selection.failedToDeleteAssets'),
|
||||
life: 3000
|
||||
})
|
||||
} else {
|
||||
// Partial success
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t('g.warning'),
|
||||
detail: t('mediaAsset.selection.partialDeleteSuccess', {
|
||||
succeeded,
|
||||
failed: failed.length
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete assets:', error)
|
||||
toast.add({
|
||||
@@ -330,11 +477,9 @@ export function useMediaAssetActions() {
|
||||
confirmDelete,
|
||||
deleteAsset,
|
||||
deleteMultipleAssets,
|
||||
playAsset,
|
||||
copyJobId,
|
||||
addWorkflow,
|
||||
openWorkflow,
|
||||
exportWorkflow,
|
||||
openMoreOutputs
|
||||
exportWorkflow
|
||||
}
|
||||
}
|
||||
|
||||
24
src/platform/assets/utils/assetTypeUtil.ts
Normal file
24
src/platform/assets/utils/assetTypeUtil.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Utilities for working with asset types
|
||||
*/
|
||||
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
|
||||
/**
|
||||
* Extract asset type from an asset's tags array
|
||||
* Falls back to a default type if tags are not present
|
||||
*
|
||||
* @param asset The asset to extract type from
|
||||
* @param defaultType Default type to use if tags are empty (default: 'output')
|
||||
* @returns The asset type ('input', 'output', 'temp', etc.)
|
||||
*
|
||||
* @example
|
||||
* getAssetType(asset) // Returns 'output' or first tag
|
||||
* getAssetType(asset, 'input') // Returns 'input' if no tags
|
||||
*/
|
||||
export function getAssetType(
|
||||
asset: AssetItem,
|
||||
defaultType: 'input' | 'output' = 'output'
|
||||
): string {
|
||||
return asset.tags?.[0] || defaultType
|
||||
}
|
||||
29
src/platform/assets/utils/assetUrlUtil.ts
Normal file
29
src/platform/assets/utils/assetUrlUtil.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Utilities for constructing asset URLs
|
||||
*/
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetType } from './assetTypeUtil'
|
||||
|
||||
/**
|
||||
* Get the download/view URL for an asset
|
||||
* Constructs the proper URL with filename encoding and type parameter
|
||||
*
|
||||
* @param asset The asset to get URL for
|
||||
* @param defaultType Default type if asset doesn't have tags (default: 'output')
|
||||
* @returns Full URL for viewing/downloading the asset
|
||||
*
|
||||
* @example
|
||||
* const url = getAssetUrl(asset)
|
||||
* downloadFile(url, asset.name)
|
||||
*/
|
||||
export function getAssetUrl(
|
||||
asset: AssetItem,
|
||||
defaultType: 'input' | 'output' = 'output'
|
||||
): string {
|
||||
const assetType = getAssetType(asset, defaultType)
|
||||
return api.apiURL(
|
||||
`/view?filename=${encodeURIComponent(asset.name)}&type=${assetType}`
|
||||
)
|
||||
}
|
||||
123
src/platform/workflow/core/services/workflowActionsService.ts
Normal file
123
src/platform/workflow/core/services/workflowActionsService.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared workflow actions service
|
||||
* Provides reusable workflow operations that can be used by both
|
||||
* job menu and media asset actions
|
||||
*/
|
||||
|
||||
import { downloadBlob } from '@/scripts/utils'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Provides shared workflow actions
|
||||
* These operations are used by multiple contexts (jobs, assets)
|
||||
* to avoid code duplication while maintaining flexibility
|
||||
*/
|
||||
export function useWorkflowActionsService() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const settingStore = useSettingStore()
|
||||
const dialogService = useDialogService()
|
||||
|
||||
/**
|
||||
* Export workflow as JSON file with optional filename prompt
|
||||
*
|
||||
* @param workflow The workflow data to export
|
||||
* @param defaultFilename Default filename to use
|
||||
* @returns Result of the export operation
|
||||
*
|
||||
* @example
|
||||
* const result = await exportWorkflowAction(workflow, 'MyWorkflow.json')
|
||||
* if (result.success) {
|
||||
* toast.add({ severity: 'success', detail: 'Exported!' })
|
||||
* }
|
||||
*/
|
||||
const exportWorkflowAction = async (
|
||||
workflow: ComfyWorkflowJSON | null,
|
||||
defaultFilename: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}> => {
|
||||
if (!workflow) {
|
||||
return { success: false, error: 'No workflow data available' }
|
||||
}
|
||||
|
||||
try {
|
||||
let filename = defaultFilename
|
||||
|
||||
// Optionally prompt for custom filename
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await dialogService.prompt({
|
||||
title: t('workflowService.exportWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: filename
|
||||
})
|
||||
// User cancelled the prompt
|
||||
if (!input) return { success: false }
|
||||
filename = appendJsonExt(input)
|
||||
}
|
||||
|
||||
// Convert workflow to formatted JSON
|
||||
const json = JSON.stringify(workflow, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
downloadBlob(filename, blob)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to export workflow'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open workflow in new tab
|
||||
* Creates a temporary workflow and opens it via the workflow service
|
||||
*
|
||||
* @param workflow The workflow data to open
|
||||
* @param filename Filename for the temporary workflow
|
||||
* @returns Result of the open operation
|
||||
*
|
||||
* @example
|
||||
* const result = await openWorkflowAction(workflow, 'Job 123.json')
|
||||
* if (!result.success) {
|
||||
* toast.add({ severity: 'error', detail: result.error })
|
||||
* }
|
||||
*/
|
||||
const openWorkflowAction = async (
|
||||
workflow: ComfyWorkflowJSON | null,
|
||||
filename: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
error?: string
|
||||
}> => {
|
||||
if (!workflow) {
|
||||
return { success: false, error: 'No workflow data available' }
|
||||
}
|
||||
|
||||
try {
|
||||
const temp = workflowStore.createTemporary(filename, workflow)
|
||||
await workflowService.openWorkflow(temp)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'Failed to open workflow'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exportWorkflowAction,
|
||||
openWorkflowAction
|
||||
}
|
||||
}
|
||||
93
src/platform/workflow/utils/workflowExtractionUtil.ts
Normal file
93
src/platform/workflow/utils/workflowExtractionUtil.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Utilities for extracting workflows from different sources
|
||||
* Supports both job-based and asset-based workflow extraction
|
||||
*/
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
|
||||
/**
|
||||
* Extract workflow from AssetItem (async - may need file fetch)
|
||||
* Tries metadata first (for output assets), then falls back to extracting from file
|
||||
* This supports both output assets (with embedded metadata) and input assets (PNG with workflow)
|
||||
*
|
||||
* @param asset The asset item to extract workflow from
|
||||
* @returns WorkflowSource with workflow and generated filename
|
||||
*
|
||||
* @example
|
||||
* const asset = { name: 'output.png', user_metadata: { workflow: {...} } }
|
||||
* const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
||||
*/
|
||||
export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
|
||||
workflow: ComfyWorkflowJSON | null
|
||||
filename: string
|
||||
}> {
|
||||
const baseFilename = asset.name.replace(/\.[^/.]+$/, '.json')
|
||||
|
||||
// Strategy 1: Try metadata first (for output assets)
|
||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
||||
if (metadata?.workflow) {
|
||||
return {
|
||||
workflow: metadata.workflow as ComfyWorkflowJSON,
|
||||
filename: baseFilename
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Try extracting from file (for input assets with embedded workflow)
|
||||
// This supports PNG, WEBP, FLAC, and other formats with metadata
|
||||
try {
|
||||
const fileUrl = getAssetUrl(asset)
|
||||
const response = await fetch(fileUrl)
|
||||
if (!response.ok) {
|
||||
return { workflow: null, filename: baseFilename }
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], asset.name, { type: blob.type })
|
||||
|
||||
const workflowData = await getWorkflowDataFromFile(file)
|
||||
if (workflowData?.workflow) {
|
||||
// Handle both string and object workflow data
|
||||
const workflow =
|
||||
typeof workflowData.workflow === 'string'
|
||||
? JSON.parse(workflowData.workflow)
|
||||
: workflowData.workflow
|
||||
|
||||
return {
|
||||
workflow: workflow as ComfyWorkflowJSON,
|
||||
filename: baseFilename
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to extract workflow from asset:', error)
|
||||
}
|
||||
|
||||
return {
|
||||
workflow: null,
|
||||
filename: baseFilename
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file format supports embedded workflow metadata
|
||||
* Useful for UI to show/hide workflow-related options
|
||||
*
|
||||
* @param filename The filename to check
|
||||
* @returns true if the format can contain workflow metadata
|
||||
*
|
||||
* @example
|
||||
* supportsWorkflowMetadata('image.png') // true
|
||||
* supportsWorkflowMetadata('image.jpg') // false
|
||||
*/
|
||||
export function supportsWorkflowMetadata(filename: string): boolean {
|
||||
const lower = filename.toLowerCase()
|
||||
return (
|
||||
lower.endsWith('.png') ||
|
||||
lower.endsWith('.webp') ||
|
||||
lower.endsWith('.flac') ||
|
||||
lower.endsWith('.json')
|
||||
)
|
||||
}
|
||||
38
src/utils/loaderNodeUtil.ts
Normal file
38
src/utils/loaderNodeUtil.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Utilities for detecting and configuring loader nodes
|
||||
* Used by both job menu and media asset actions to determine
|
||||
* which loader node type to add to the canvas
|
||||
*/
|
||||
|
||||
import { getMediaTypeFromFilename } from '@comfyorg/shared-frontend-utils/formatUtil'
|
||||
|
||||
/**
|
||||
* Detect loader node type from filename extension
|
||||
* Uses shared formatUtil for consistent file type detection across the codebase
|
||||
*
|
||||
* @param filename The filename to check
|
||||
* @returns Object with nodeType and widgetName, or nulls if unsupported
|
||||
*
|
||||
* @example
|
||||
* detectNodeTypeFromFilename('image.png') // { nodeType: 'LoadImage', widgetName: 'image' }
|
||||
* detectNodeTypeFromFilename('video.mp4') // { nodeType: 'LoadVideo', widgetName: 'file' }
|
||||
* detectNodeTypeFromFilename('audio.mp3') // { nodeType: 'LoadAudio', widgetName: 'audio' }
|
||||
*/
|
||||
export function detectNodeTypeFromFilename(filename: string): {
|
||||
nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null
|
||||
widgetName: 'image' | 'file' | 'audio' | null
|
||||
} {
|
||||
const mediaType = getMediaTypeFromFilename(filename)
|
||||
|
||||
switch (mediaType) {
|
||||
case 'image':
|
||||
return { nodeType: 'LoadImage', widgetName: 'image' }
|
||||
case 'video':
|
||||
return { nodeType: 'LoadVideo', widgetName: 'file' }
|
||||
case 'audio':
|
||||
return { nodeType: 'LoadAudio', widgetName: 'audio' }
|
||||
default:
|
||||
// 3D and other types don't have loader nodes
|
||||
return { nodeType: null, widgetName: null }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
LGraphNode,
|
||||
Subgraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ResultItemType } from '@/schemas/apiSchema'
|
||||
|
||||
/**
|
||||
* Check if an error is an AbortError triggered by `AbortController#abort`
|
||||
@@ -49,3 +50,13 @@ export const isSlotObject = (obj: unknown): obj is INodeSlot => {
|
||||
'boundingRect' in obj
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a string is a valid ResultItemType
|
||||
* ResultItemType is used for asset categorization (input/output/temp)
|
||||
*/
|
||||
export const isResultItemType = (
|
||||
value: string | undefined
|
||||
): value is ResultItemType => {
|
||||
return value === 'input' || value === 'output' || value === 'temp'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user