Merge branch 'main' into bl-merge-lg-fe

This commit is contained in:
Benjamin Lu
2025-08-05 18:24:29 -04:00
committed by GitHub
7 changed files with 155 additions and 18 deletions

View File

@@ -6,6 +6,9 @@
- `npm run typecheck`: Type checking - `npm run typecheck`: Type checking
- `npm run lint`: Linting - `npm run lint`: Linting
- `npm run format`: Prettier formatting - `npm run format`: Prettier formatting
- `npm run test:component`: Run component tests with browser environment
- `npm run test:unit`: Run all unit tests
- `npm run test:unit -- tests-ui/tests/example.test.ts`: Run single test file
## Development Workflow ## Development Workflow
@@ -48,3 +51,11 @@ When referencing Comfy-Org repos:
1. Check for local copy 1. Check for local copy
2. Use GitHub API for branches/PRs/metadata 2. Use GitHub API for branches/PRs/metadata
3. Curl GitHub website if needed 3. Curl GitHub website if needed
## Common Pitfalls
- NEVER use `any` type - use proper TypeScript types
- NEVER use `as any` type assertions - fix the underlying type issue
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks

View File

@@ -6,9 +6,22 @@
- Use `api.apiURL()` for backend endpoints - Use `api.apiURL()` for backend endpoints
- Use `api.fileURL()` for static files - Use `api.fileURL()` for static files
- Examples:
- Backend: `api.apiURL('/prompt')` #### ✅ Correct Usage
- Static: `api.fileURL('/templates/default.json')` ```typescript
// Backend API call
const response = await api.get(api.apiURL('/prompt'))
// Static file
const template = await fetch(api.fileURL('/templates/default.json'))
```
#### ❌ Incorrect Usage
```typescript
// WRONG - Direct URL construction
const response = await fetch('/api/prompt')
const template = await fetch('/templates/default.json')
```
### Error Handling ### Error Handling

View File

@@ -919,6 +919,33 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return `$${price.toFixed(2)}/Run` return `$${price.toFixed(2)}/Run`
} }
}, },
Veo3VideoGenerationNode: {
displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model'
) as IComboWidget
const generateAudioWidget = node.widgets?.find(
(w) => w.name === 'generate_audio'
) as IComboWidget
if (!modelWidget || !generateAudioWidget) {
return '$2.00-6.00/Run (varies with model & audio generation)'
}
const model = String(modelWidget.value)
const generateAudio =
String(generateAudioWidget.value).toLowerCase() === 'true'
if (model.includes('veo-3.0-fast-generate-001')) {
return generateAudio ? '$3.20/Run' : '$2.00/Run'
} else if (model.includes('veo-3.0-generate-001')) {
return generateAudio ? '$6.00/Run' : '$4.00/Run'
}
// Default fallback
return '$2.00-6.00/Run'
}
},
LumaImageNode: { LumaImageNode: {
displayPrice: (node: LGraphNode): string => { displayPrice: (node: LGraphNode): string => {
const modelWidget = node.widgets?.find( const modelWidget = node.widgets?.find(
@@ -1340,6 +1367,7 @@ export const useNodePricing = () => {
FluxProKontextProNode: [], FluxProKontextProNode: [],
FluxProKontextMaxNode: [], FluxProKontextMaxNode: [],
VeoVideoGenerationNode: ['duration_seconds'], VeoVideoGenerationNode: ['duration_seconds'],
Veo3VideoGenerationNode: ['model', 'generate_audio'],
LumaVideoNode: ['model', 'resolution', 'duration'], LumaVideoNode: ['model', 'resolution', 'duration'],
LumaImageToVideoNode: ['model', 'resolution', 'duration'], LumaImageToVideoNode: ['model', 'resolution', 'duration'],
LumaImageNode: ['model', 'aspect_ratio'], LumaImageNode: ['model', 'aspect_ratio'],

View File

@@ -847,10 +847,13 @@ export const useLitegraphService = () => {
const isAnimatedWebp = const isAnimatedWebp =
this.animatedImages && this.animatedImages &&
// @ts-expect-error fixme ts strict error output?.images?.some((img) => img.filename?.includes('webp'))
output.images.some((img) => img.filename?.includes('webp')) const isAnimatedPng =
this.animatedImages &&
output?.images?.some((img) => img.filename?.includes('png'))
const isVideo = const isVideo =
(this.animatedImages && !isAnimatedWebp) || isVideoNode(this) (this.animatedImages && !isAnimatedWebp && !isAnimatedPng) ||
isVideoNode(this)
if (isVideo) { if (isVideo) {
useNodeVideo(this).showPreview() useNodeVideo(this).showPreview()
} else { } else {

View File

@@ -21,7 +21,10 @@ const createOutputs = (
): ExecutedWsMessage['output'] => { ): ExecutedWsMessage['output'] => {
return { return {
images: filenames.map((image) => ({ type, ...parseFilePath(image) })), images: filenames.map((image) => ({ type, ...parseFilePath(image) })),
animated: filenames.map((image) => isAnimated && image.endsWith('.webp')) animated: filenames.map(
(image) =>
isAnimated && (image.endsWith('.webp') || image.endsWith('.png'))
)
} }
} }

View File

@@ -3,11 +3,8 @@ import type {
ExecutionId, ExecutionId,
LGraph LGraph
} from '@/lib/litegraph/src/litegraph' } from '@/lib/litegraph/src/litegraph'
import { import { ExecutableNodeDTO, LGraphEventMode } from '@/lib/litegraph/src/litegraph'
ExecutableNodeDTO,
LGraphEventMode,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { import type {
ComfyApiWorkflow, ComfyApiWorkflow,
ComfyWorkflowJSON ComfyWorkflowJSON
@@ -62,12 +59,7 @@ export const graphToPrompt = async (
for (const node of graph.computeExecutionOrder(false)) { for (const node of graph.computeExecutionOrder(false)) {
const dto: ExecutableLGraphNode = isGroupNode(node) const dto: ExecutableLGraphNode = isGroupNode(node)
? new ExecutableGroupNodeDTO(node, [], nodeDtoMap) ? new ExecutableGroupNodeDTO(node, [], nodeDtoMap)
: new ExecutableNodeDTO( : new ExecutableNodeDTO(node, [], nodeDtoMap)
node,
[],
nodeDtoMap,
node instanceof SubgraphNode ? node : undefined
)
for (const innerNode of dto.getInnerNodes()) { for (const innerNode of dto.getInnerNodes()) {
nodeDtoMap.set(innerNode.id, innerNode) nodeDtoMap.set(innerNode.id, innerNode)

View File

@@ -393,6 +393,86 @@ describe('useNodePricing', () => {
}) })
}) })
describe('dynamic pricing - Veo3VideoGenerationNode', () => {
it('should return $2.00 for veo-3.0-fast-generate-001 without audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-fast-generate-001' },
{ name: 'generate_audio', value: false }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$2.00/Run')
})
it('should return $3.20 for veo-3.0-fast-generate-001 with audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-fast-generate-001' },
{ name: 'generate_audio', value: true }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$3.20/Run')
})
it('should return $4.00 for veo-3.0-generate-001 without audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: false }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$4.00/Run')
})
it('should return $6.00 for veo-3.0-generate-001 with audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: true }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$6.00/Run')
})
it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)'
)
})
it('should return range when only model widget is present', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)'
)
})
it('should return range when only generate_audio widget is present', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'generate_audio', value: true }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)'
)
})
})
describe('dynamic pricing - LumaVideoNode', () => { describe('dynamic pricing - LumaVideoNode', () => {
it('should return $2.19 for ray-flash-2 4K 5s', () => { it('should return $2.19 for ray-flash-2 4K 5s', () => {
const { getNodeDisplayPrice } = useNodePricing() const { getNodeDisplayPrice } = useNodePricing()
@@ -736,6 +816,13 @@ describe('useNodePricing', () => {
expect(widgetNames).toEqual(['duration_seconds']) expect(widgetNames).toEqual(['duration_seconds'])
}) })
it('should return correct widget names for Veo3VideoGenerationNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('Veo3VideoGenerationNode')
expect(widgetNames).toEqual(['model', 'generate_audio'])
})
it('should return correct widget names for LumaVideoNode', () => { it('should return correct widget names for LumaVideoNode', () => {
const { getRelevantWidgetNames } = useNodePricing() const { getRelevantWidgetNames } = useNodePricing()