From a78dfc284ea8a8890e5095749df3f62d38072179 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 7 Nov 2026 13:29:49 -0800 Subject: [PATCH] fix(video): read the active start frame input when serializing video settings Sub-block conditions in the serializer and on the canvas card did not get the block's canonical mode overrides, so the Video Generator guessed the active start frame input from which member held a value. A frame left in the inactive input hid prompt-only settings (fal aspect ratio, Runway ratio) and dropped them from the request. A shared withCanonicalModes helper now feeds the same condition values to the serializer, canvas card, editor layout, autolayout and dropdown options. --- .../components/dropdown/dropdown.tsx | 6 +- .../hooks/use-editor-subblock-layout.ts | 18 +-- apps/sim/lib/workflows/autolayout/utils.ts | 10 +- .../blocks/canvas-card-fields.test.ts | 15 +++ .../workflows/blocks/canvas-card-fields.ts | 5 +- .../sim/lib/workflows/subblocks/visibility.ts | 12 ++ apps/sim/serializer/field-analysis.test.ts | 123 +++++++++--------- apps/sim/serializer/index.ts | 5 +- 8 files changed, 108 insertions(+), 86 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 96dfa8108af..f81cb1b7721 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -9,6 +9,7 @@ import { import type { SelectorKey } from '@/lib/selectors/manifest' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { getDependsOnFields } from '@/lib/workflows/subblocks/dependencies' +import { withCanonicalModes } from '@/lib/workflows/subblocks/visibility' import { staleSelectionOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/stale-selections' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' @@ -172,10 +173,7 @@ export const Dropdown = memo(function Dropdown({ ) const evaluatedOptions = useMemo(() => { if (typeof options !== 'function') return options ?? EMPTY_OPTIONS - const values = blockValues ?? {} - return options({ - values: canonicalModes ? { ...values, __canonicalModes: canonicalModes } : values, - }) + return options({ values: withCanonicalModes(blockValues ?? {}, canonicalModes) }) }, [options, blockValues, canonicalModes]) const reactiveOptions = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts index d9feec0800e..000f0701c29 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts @@ -1,12 +1,14 @@ import { useCallback, useMemo } from 'react' import { buildCanonicalIndexForSurface, + buildSubBlockValues, evaluateSubBlockCondition, isSubBlockFeatureEnabled, isSubBlockHidden, isSubBlockVisibleForMode, isSubBlockVisibleForTriggerMode, isToolInputOnlySubBlock, + withCanonicalModes, } from '@/lib/workflows/subblocks/visibility' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -93,24 +95,10 @@ export function useEditorSubblockLayout( }) } - // Filter visible blocks and those that meet their conditions - const rawValues = Object.entries(stateToUse).reduce>( - (acc, [key, entry]) => { - acc[key] = entry?.value - return acc - }, - {} - ) - const canonicalIndex = buildCanonicalIndexForSurface(config.subBlocks || [], displayTriggerMode) const effectiveAdvanced = displayAdvancedMode const canonicalModeOverrides = blockData?.canonicalModes - - // Expose canonical mode overrides to condition functions so they can - // react to basic/advanced credential toggles (e.g. SERVICE_ACCOUNT_SUBBLOCKS). - if (canonicalModeOverrides) { - rawValues.__canonicalModes = canonicalModeOverrides - } + const rawValues = withCanonicalModes(buildSubBlockValues(stateToUse), canonicalModeOverrides) const visibleSubBlocks = (config.subBlocks || []).filter((block) => { if (block.hidden) return false diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index 91e9eefba2d..a4364464ac3 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -37,6 +37,7 @@ import { isSubBlockVisibleForMode, isToolInputOnlySubBlock, isTriggerModeSubBlock, + withCanonicalModes, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' @@ -188,15 +189,14 @@ function getVisiblePreviewSubBlocks(block: BlockState): { } } - const rawValues = buildSubBlockValues(block.subBlocks || {}) const canonicalModeOverrides = typeof block.data?.canonicalModes === 'object' && block.data.canonicalModes !== null ? (block.data.canonicalModes as CanonicalModeOverrides) : undefined - - if (canonicalModeOverrides) { - rawValues.__canonicalModes = canonicalModeOverrides - } + const rawValues = withCanonicalModes( + buildSubBlockValues(block.subBlocks || {}), + canonicalModeOverrides + ) const effectiveAdvanced = Boolean(block.advancedMode) const effectiveTrigger = Boolean(block.triggerMode) diff --git a/apps/sim/lib/workflows/blocks/canvas-card-fields.test.ts b/apps/sim/lib/workflows/blocks/canvas-card-fields.test.ts index 796f673414a..f6c946d06a8 100644 --- a/apps/sim/lib/workflows/blocks/canvas-card-fields.test.ts +++ b/apps/sim/lib/workflows/blocks/canvas-card-fields.test.ts @@ -95,4 +95,19 @@ describe('getCardSubBlocks', () => { const onCard = triggerCardIds(BLOCK_REGISTRY.webflow) expect(onCard).toEqual(expect.arrayContaining(['triggerCredentials', 'triggerSiteId'])) }) + + it('reads the active start frame member when picking video settings', () => { + // The stale upload is the inactive member, so the card shows the prompt-only settings. + const videoGenerator = BLOCK_REGISTRY.video_generator_v3 + const onCard = getCardSubBlocks(videoGenerator, { + advanced: true, + values: { + provider: 'falai', + model: 'alibaba/happy-horse/text-to-video', + visualReferenceUpload: { name: 'frame.png', key: 'k', size: 3, type: 'image/png' }, + }, + canonicalModeOverrides: { visualReference: 'advanced' }, + }).map((subBlock) => subBlock.id) + expect(onCard).toContain('falAspectRatio') + }) }) diff --git a/apps/sim/lib/workflows/blocks/canvas-card-fields.ts b/apps/sim/lib/workflows/blocks/canvas-card-fields.ts index 8dfb5de1260..39388c1390d 100644 --- a/apps/sim/lib/workflows/blocks/canvas-card-fields.ts +++ b/apps/sim/lib/workflows/blocks/canvas-card-fields.ts @@ -7,6 +7,7 @@ import { isSubBlockVisibleForMode, isToolInputOnlySubBlock, isTriggerModeSubBlock, + withCanonicalModes, } from '@/lib/workflows/subblocks/visibility' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' @@ -88,6 +89,7 @@ export function getCardSubBlocks( } = options const canonicalIndex = buildCanonicalIndexForSurface(config.subBlocks, triggerMode) + const conditionValues = withCanonicalModes(values, canonicalModeOverrides) const isPureTriggerBlock = Boolean(config.triggers?.enabled && config.category === 'triggers') return config.subBlocks.filter((subBlock) => { @@ -114,7 +116,8 @@ export function getCardSubBlocks( return false } - if (subBlock.condition && !evaluateSubBlockCondition(subBlock.condition, values)) return false + if (subBlock.condition && !evaluateSubBlockCondition(subBlock.condition, conditionValues)) + return false /* The operation is the card's heading here, so it is not also a field. */ if (titleOperationSubBlockId && subBlock.id === titleOperationSubBlockId) return false diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index 15b797fe72f..62eb82b169d 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -94,6 +94,18 @@ export function buildSubBlockValues( }, {}) } +/** + * The values a block's sub-block conditions read: its values plus, when set, its canonical mode + * overrides under `__canonicalModes`, so a condition reads the active member of a basic/advanced + * pair instead of guessing it from which member holds a value. + */ +export function withCanonicalModes( + values: Record, + canonicalModes: CanonicalModeOverrides | undefined +): Record { + return canonicalModes ? { ...values, __canonicalModes: canonicalModes } : values +} + /** * Build canonical group indices for a block's subblocks. */ diff --git a/apps/sim/serializer/field-analysis.test.ts b/apps/sim/serializer/field-analysis.test.ts index 30ea8241171..6aa8300a48f 100644 --- a/apps/sim/serializer/field-analysis.test.ts +++ b/apps/sim/serializer/field-analysis.test.ts @@ -138,6 +138,27 @@ describe('collectBlockFieldIssues', () => { }) }) +const FRAME_FILE = { id: 'f', name: 'frame.png', url: '', size: 3, type: 'image/png', key: 'k' } +const FAL_VALUES = { provider: 'falai', apiKey: 'test-key', prompt: 'A cinematic sunrise' } + +/** Serializes a Video Generator block and maps its params to the video tool body. */ +function serializeVideoBlock( + values: Record, + canonicalModes?: Record +) { + svcConfig.value = VideoGeneratorV3Block + const params = extractBlockParams( + block({ + type: 'svc', + data: canonicalModes ? { canonicalModes } : {}, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) + ), + }) + ) + return videoToolBodySchema.parse(VideoGeneratorV3Block.tools.config?.params?.(params)) +} + describe('extractBlockParams', () => { it.each([ { @@ -191,19 +212,8 @@ describe('extractBlockParams', () => { }, }, ])('preserves $name through serialization into the provider request', ({ settings, payload }) => { - svcConfig.value = VideoGeneratorV3Block - const values = { apiKey: 'test-key', prompt: 'A cinematic sunrise', ...settings } - const params = extractBlockParams( - block({ - type: 'svc', - subBlocks: Object.fromEntries( - Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) - ), - }) - ) - const mapped = VideoGeneratorV3Block.tools.config?.params?.(params) const request = buildDirectVideoRequest({ - ...videoToolBodySchema.parse(mapped), + ...serializeVideoBlock({ apiKey: 'test-key', prompt: 'A cinematic sunrise', ...settings }), provider: settings.provider, }) expect(request.body).toMatchObject(payload) @@ -227,34 +237,13 @@ describe('extractBlockParams', () => { ])( 'sends the model default for a fal setting the endpoint rejects when $name', ({ model, aspectRatio, frame, endpoint }) => { - svcConfig.value = VideoGeneratorV3Block - const frameFile = { - id: 'f', - name: 'frame.png', - url: '', - size: 3, - type: 'image/png', - key: 'k', - } - const values = { - provider: 'falai', - apiKey: 'test-key', - model, - prompt: 'A cinematic sunrise', - falAspectRatio: aspectRatio, - ...(frame ? { visualReferenceInput: frameFile } : {}), - } - const params = extractBlockParams( - block({ - type: 'svc', - subBlocks: Object.fromEntries( - Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) - ), - }) - ) - const mapped = VideoGeneratorV3Block.tools.config?.params?.(params) const request = buildFalVideoRequest({ - ...videoToolBodySchema.parse(mapped), + ...serializeVideoBlock({ + ...FAL_VALUES, + model, + falAspectRatio: aspectRatio, + ...(frame ? { visualReferenceInput: FRAME_FILE } : {}), + }), provider: 'falai', }) expect(request.endpoint).toBe(endpoint) @@ -262,28 +251,44 @@ describe('extractBlockParams', () => { } ) + it.each([ + { + name: 'a frame left in the inactive upload', + mode: 'advanced', + frameField: 'visualReferenceUpload', + }, + { + name: 'a frame left in the inactive reference input', + mode: 'basic', + frameField: 'visualReferenceInput', + }, + ])('keeps a prompt-only fal setting with $name', ({ mode, frameField }) => { + const request = buildFalVideoRequest({ + ...serializeVideoBlock( + { + ...FAL_VALUES, + model: 'alibaba/happy-horse/text-to-video', + falAspectRatio: '16:9', + [frameField]: FRAME_FILE, + }, + { visualReference: mode } + ), + provider: 'falai', + }) + expect(request.endpoint).toBe('alibaba/happy-horse/text-to-video') + expect(request.body).toMatchObject({ aspect_ratio: '16:9' }) + }) + it('sends the model default for a Runway setting the start frame endpoint rejects', () => { - svcConfig.value = VideoGeneratorV3Block - const frameFile = { id: 'f', name: 'frame.png', url: '', size: 3, type: 'image/png', key: 'k' } - const values = { - provider: 'runway', - apiKey: 'test-key', - model: 'kling_4', - prompt: 'A cinematic sunrise', - aspectRatio: '1920:1080', - visualReferenceInput: frameFile, - } - const params = extractBlockParams( - block({ - type: 'svc', - subBlocks: Object.fromEntries( - Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) - ), - }) - ) - const mapped = VideoGeneratorV3Block.tools.config?.params?.(params) const request = buildDirectVideoRequest({ - ...videoToolBodySchema.parse(mapped), + ...serializeVideoBlock({ + provider: 'runway', + apiKey: 'test-key', + model: 'kling_4', + prompt: 'A cinematic sunrise', + aspectRatio: '1920:1080', + visualReferenceInput: FRAME_FILE, + }), provider: 'runway', }) expect(request.body).not.toHaveProperty('ratio') diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index d7e44f100f3..05f9dbc8b8d 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -20,6 +20,7 @@ import { isSubBlockHidden, isToolInputOnlySubBlock, resolveCanonicalMode, + withCanonicalModes, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' import { isCustomBlockType, RESERVED_PARAMS } from '@/blocks/custom/build-config' @@ -551,7 +552,7 @@ export function extractBlockParams(block: BlockState): Record { // canonical-index-unscoped: `shouldSerializeSubBlock` drops the inactive surface's members, and // the collapse below reads `params` (the filtered map), never `allValues`. const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) - const allValues = buildSubBlockValues(block.subBlocks) + const allValues = withCanonicalModes(buildSubBlockValues(block.subBlocks), canonicalModeOverrides) Object.entries(block.subBlocks).forEach(([id, subBlock]) => { const matchingConfigs = blockConfig.subBlocks.filter((config) => config.id === id) @@ -707,7 +708,7 @@ export function collectBlockFieldIssues( // trigger-mode block returns above before reaching here at all. const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks || []) const canonicalModeOverrides = block.data?.canonicalModes - const allValues = buildSubBlockValues(block.subBlocks) + const allValues = withCanonicalModes(buildSubBlockValues(block.subBlocks), canonicalModeOverrides) // Get the tool configuration to check parameter visibility const toolAccess = blockConfig.tools?.access