[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:
Jin Yi
2025-11-18 09:04:45 +09:00
committed by GitHub
parent 2f81e5b30a
commit a4d979e4c9
12 changed files with 633 additions and 124 deletions

View 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
}
}

View 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')
)
}