From 31b75cc4c6cecac657cc70b84f35fab85cee9679 Mon Sep 17 00:00:00 2001 From: TianYun Date: Fri, 12 Jun 2026 11:06:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=9F=B3=E9=A2=91=20?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/GenerationComposer.test.tsx | 78 ++++++++++++++-- .../__tests__/ModelParameters.test.tsx | 47 +++++++++- .../composer/GenerationComposer.tsx | 31 +++++-- src/components/nodes/GenerateAudioNode.tsx | 2 + src/components/nodes/ModelParameters.tsx | 14 ++- src/i18n/index.tsx | 8 +- .../__tests__/generateVideoExecutor.test.ts | 92 ++++++++++++++++--- src/store/execution/generateVideoExecutor.ts | 25 +++-- 8 files changed, 251 insertions(+), 46 deletions(-) diff --git a/src/components/__tests__/GenerationComposer.test.tsx b/src/components/__tests__/GenerationComposer.test.tsx index cd91fc5c..a44667e1 100644 --- a/src/components/__tests__/GenerationComposer.test.tsx +++ b/src/components/__tests__/GenerationComposer.test.tsx @@ -1692,7 +1692,7 @@ describe("GenerationComposer", () => { }); render(); - fireEvent.click(screen.getByText("First/last frame")); + fireEvent.click(screen.getByText("首尾帧")); await waitFor(() => { const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData; @@ -1714,7 +1714,7 @@ describe("GenerationComposer", () => { }); render(); - fireEvent.click(screen.getByText("Image to video")); + fireEvent.click(screen.getByText("图生视频")); await waitFor(() => { const data = useWorkflowStore.getState().nodes[1].data as GenerateVideoNodeData; @@ -1738,7 +1738,38 @@ describe("GenerationComposer", () => { }); render(); - fireEvent.click(screen.getByText("Image to video")); + fireEvent.click(screen.getByText("图生视频")); + + await waitFor(() => { + const data = useWorkflowStore.getState().nodes[2].data as GenerateVideoNodeData; + expect(data.parameters?.subType).toBe(203); + }); + }); + + it("does not allow image-to-video when video material is connected", async () => { + useWorkflowStore.setState({ + nodes: [ + imageNode("img-a", false, { outputImage: "https://static.popi.art/a.jpg" }), + videoNode("source-video", false, { outputVideo: "https://static.popi.art/source.mp4" }), + videoNode("vid-1", true, { + parameters: { subType: 203 }, + }), + ], + edges: [ + imageEdge("img-a", "vid-1", 0), + { + id: "edge-source-video-vid-1-output-input", + source: "source-video", + sourceHandle: "output", + target: "vid-1", + targetHandle: "input", + data: { createdAt: 1 }, + } as WorkflowEdge, + ], + }); + render(); + + fireEvent.click(screen.getByText("图生视频")); await waitFor(() => { const data = useWorkflowStore.getState().nodes[2].data as GenerateVideoNodeData; @@ -1768,6 +1799,37 @@ describe("GenerationComposer", () => { }); }); + it("falls back from first/last frame when audio material is connected", async () => { + useWorkflowStore.setState({ + nodes: [ + imageNode("img-a", false, { outputImage: "https://static.popi.art/a.jpg" }), + imageNode("img-b", false, { outputImage: "https://static.popi.art/b.jpg" }), + audioNode("source-audio", false, { outputAudio: "https://static.popi.art/source.mp3" }), + videoNode("vid-1", true, { + parameters: { subType: 204 }, + }), + ], + edges: [ + imageEdge("img-a", "vid-1", 0), + imageEdge("img-b", "vid-1", 1), + { + id: "edge-source-audio-vid-1-output-input", + source: "source-audio", + sourceHandle: "output", + target: "vid-1", + targetHandle: "input", + data: { createdAt: 2 }, + } as WorkflowEdge, + ], + }); + render(); + + await waitFor(() => { + const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData; + expect(data.parameters?.subType).toBe(203); + }); + }); + it("labels two first/last frame images without changing their reference order", async () => { useWorkflowStore.setState({ nodes: [ @@ -1785,10 +1847,10 @@ describe("GenerationComposer", () => { }); render(); - expect(screen.getByText("First")).toBeInTheDocument(); - expect(screen.getByText("Last")).toBeInTheDocument(); + expect(screen.getByText("首帧")).toBeInTheDocument(); + expect(screen.getByText("尾帧")).toBeInTheDocument(); - fireEvent.click(screen.getByLabelText("Generate")); + fireEvent.click(screen.getByLabelText(/Generate|生成/)); await waitFor(() => { expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1"); @@ -1799,13 +1861,13 @@ describe("GenerationComposer", () => { id: 1, type: "image", url: "https://static.popi.art/first.jpg", - name: "@图1", + name: "图1", }, { id: 2, type: "image", url: "https://static.popi.art/last.jpg", - name: "@图2", + name: "图2", }, ]); }); diff --git a/src/components/__tests__/ModelParameters.test.tsx b/src/components/__tests__/ModelParameters.test.tsx index 6aea389f..58800e56 100644 --- a/src/components/__tests__/ModelParameters.test.tsx +++ b/src/components/__tests__/ModelParameters.test.tsx @@ -26,7 +26,7 @@ describe("ModelParameters", () => { const { rerender } = render( ); - expect(screen.getByText("Loading parameters...")).toBeInTheDocument(); + expect(screen.getByText(/Loading parameters|正在加载参数/)).toBeInTheDocument(); rerender( @@ -170,11 +170,52 @@ describe("ModelParameters", () => { /> ); - expect(screen.getByText("Quality")).toBeInTheDocument(); - expect(screen.queryByText("Size")).not.toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.queryByText("1024 x 1024")).not.toBeInTheDocument(); expect(onParametersChange).toHaveBeenCalledWith({}); await waitFor(() => { expect(onParametersChange).toHaveBeenCalledWith({ quality: "auto" }); }); }); + + it("can hide parameters without pruning stored values", async () => { + const onParametersChange = vi.fn(); + render( + + ); + + expect(screen.queryByText("VoiceId")).not.toBeInTheDocument(); + await waitFor(() => { + expect(onParametersChange).toHaveBeenCalledWith({ + voiceId: "male-qn-qingse", + quality: "auto", + }); + }); + }); }); diff --git a/src/components/composer/GenerationComposer.tsx b/src/components/composer/GenerationComposer.tsx index 0bd4e3b9..c6211e16 100644 --- a/src/components/composer/GenerationComposer.tsx +++ b/src/components/composer/GenerationComposer.tsx @@ -97,6 +97,11 @@ type VideoStitchLoopCount = 1 | 2 | 3; type VideoGenerationSubType = 202 | 203 | 204; type ComposerReferenceMaterialType = "image" | "video" | "audio"; type AssistMenuType = "command" | "reference"; +type ReferenceMediaCounts = { + images: number; + videos: number; + audio: number; +}; interface ComposerReferenceMaterial { id: number; @@ -247,12 +252,12 @@ function videoGenerationSubTypeFromUnknown(value: unknown): VideoGenerationSubTy return isVideoGenerationSubType(parsed) ? parsed : null; } -function canUseFirstLastFrameSubType(imageCount: number): boolean { - return imageCount === 2; +function canUseFirstLastFrameSubType(counts: ReferenceMediaCounts): boolean { + return counts.images === 2 && counts.videos === 0 && counts.audio === 0; } -function canUseImageToVideoSubType(imageCount: number): boolean { - return imageCount === 1; +function canUseImageToVideoSubType(counts: ReferenceMediaCounts): boolean { + return counts.images === 1 && counts.videos === 0 && counts.audio === 0; } function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] { @@ -1856,8 +1861,13 @@ export function GenerationComposer() { const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : []; const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages; const isVideoSubTypeSelectorVisible = context.mode === "node-edit" && context.nodeType === "generateVideo"; - const canUseFirstLastFrame = canUseFirstLastFrameSubType(referenceImages.length); - const canUseImageToVideo = canUseImageToVideoSubType(referenceImages.length); + const referenceMediaCounts: ReferenceMediaCounts = { + images: referenceImages.length, + videos: context.mode === "node-edit" ? connectedInputs?.videos.length ?? 0 : 0, + audio: context.mode === "node-edit" ? connectedInputs?.audio.length ?? 0 : 0, + }; + const canUseFirstLastFrame = canUseFirstLastFrameSubType(referenceMediaCounts); + const canUseImageToVideo = canUseImageToVideoSubType(referenceMediaCounts); const referenceImageInputs = useMemo(() => { const imageEdgeIds = context.mode === "node-edit" && context.node && connectedReferenceImages.length > 0 @@ -2539,6 +2549,11 @@ export function GenerationComposer() { const submitReferenceImageCount = connectedInputs?.images.length && connectedInputs.images.length > 0 ? connectedInputs.images.length : activeDraft.inputImages.length; + const submitReferenceMediaCounts: ReferenceMediaCounts = { + images: submitReferenceImageCount, + videos: connectedInputs?.videos.length ?? 0, + audio: connectedInputs?.audio.length ?? 0, + }; const referenceSubjectList = buildReferenceSubjectListParameter(referenceMaterials); const shouldWriteVideoSubType = context.mode === "empty-create" || explicitSubmitVideoSubType !== null; const shouldAttachReferenceSubjectList = @@ -2563,7 +2578,7 @@ export function GenerationComposer() { if ( submitVideoNodeType === "generateVideo" && explicitSubmitVideoSubType === VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE && - !canUseImageToVideoSubType(submitReferenceImageCount) + !canUseImageToVideoSubType(submitReferenceMediaCounts) ) { message.warning(t("composer.videoSubTypeImageToVideoImageCountRequired")); return; @@ -2571,7 +2586,7 @@ export function GenerationComposer() { if ( submitVideoNodeType === "generateVideo" && explicitSubmitVideoSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME && - !canUseFirstLastFrameSubType(submitReferenceImageCount) + !canUseFirstLastFrameSubType(submitReferenceMediaCounts) ) { message.warning(t("composer.videoSubTypeFirstLastFrameImageCountRequired")); return; diff --git a/src/components/nodes/GenerateAudioNode.tsx b/src/components/nodes/GenerateAudioNode.tsx index ccde4ab2..9182009c 100644 --- a/src/components/nodes/GenerateAudioNode.tsx +++ b/src/components/nodes/GenerateAudioNode.tsx @@ -495,6 +495,7 @@ export function GenerateAudioNodeView({ parameters={nodeConfig.parameters || {}} onParametersChange={handleParametersChange} hiddenParameterNames={AUDIO_COMPOSER_PARAMETER_NAMES} + pruneHiddenParameters={false} /> )} @@ -714,6 +715,7 @@ export function GenerateAudioNodeView({ onParametersChange={handleParametersChange} onExpandChange={handleParametersExpandChange} hiddenParameterNames={AUDIO_COMPOSER_PARAMETER_NAMES} + pruneHiddenParameters={false} /> )} diff --git a/src/components/nodes/ModelParameters.tsx b/src/components/nodes/ModelParameters.tsx index 8bd996c4..0b467982 100644 --- a/src/components/nodes/ModelParameters.tsx +++ b/src/components/nodes/ModelParameters.tsx @@ -31,6 +31,7 @@ interface ModelParametersProps { onExpandChange?: (expanded: boolean, parameterCount: number) => void; onInputsLoaded?: (inputs: ModelInputDef[]) => void; hiddenParameterNames?: string[]; + pruneHiddenParameters?: boolean; } /** @@ -46,6 +47,7 @@ function ModelParametersInner({ onParametersChange, onExpandChange, hiddenParameterNames, + pruneHiddenParameters = true, }: ModelParametersProps) { const { t } = useI18n(); const lastExpandCountRef = useRef(null); @@ -62,7 +64,7 @@ function ModelParametersInner({ // Remove parameters controlled by a higher-priority surface, such as the bottom composer. useEffect(() => { - if (hiddenParameterSet.size === 0) return; + if (!pruneHiddenParameters || hiddenParameterSet.size === 0) return; const nextParameters = { ...parameters }; let changed = false; for (const name of hiddenParameterSet) { @@ -74,14 +76,16 @@ function ModelParametersInner({ if (changed) { onParametersChange(nextParameters); } - }, [hiddenParameterSet, onParametersChange, parameters]); + }, [hiddenParameterSet, onParametersChange, parameters, pruneHiddenParameters]); // Pre-populate visible schema defaults into parameters useEffect(() => { if (visibleSchema.length === 0) return; const baseParameters = { ...parameters }; - for (const name of hiddenParameterSet) { - delete baseParameters[name]; + if (pruneHiddenParameters) { + for (const name of hiddenParameterSet) { + delete baseParameters[name]; + } } const defaults: Record = {}; let hasNewDefaults = false; @@ -101,7 +105,7 @@ function ModelParametersInner({ if (hasNewDefaults) { onParametersChange({ ...baseParameters, ...defaults }); } - }, [visibleSchema, parameters, onParametersChange]); + }, [visibleSchema, parameters, onParametersChange, hiddenParameterSet, pruneHiddenParameters]); // Notify parent to resize node when schema loads useEffect(() => { diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx index 5822348b..d685869b 100644 --- a/src/i18n/index.tsx +++ b/src/i18n/index.tsx @@ -708,8 +708,8 @@ const en = { "composer.videoSubTypeTextToVideo": "All-purpose reference", "composer.videoSubTypeFirstLastFrame": "First/last frame", "composer.videoSubTypeImageRequired": "Image to video or first/last frame requires at least one reference image.", - "composer.videoSubTypeImageToVideoImageCountRequired": "Image to video requires exactly one reference image.", - "composer.videoSubTypeFirstLastFrameImageCountRequired": "First/last frame requires exactly two reference images.", + "composer.videoSubTypeImageToVideoImageCountRequired": "Image to video requires exactly one reference image and no audio or video assets.", + "composer.videoSubTypeFirstLastFrameImageCountRequired": "First/last frame requires exactly two reference images and no audio or video assets.", "composer.firstFrame": "First", "composer.lastFrame": "Last", "composer.commands": "Commands", @@ -1854,8 +1854,8 @@ Object.assign(zhCN, { "group.colorPurple": "紫色", "group.colorOrange": "橙色", "group.colorRed": "红色", - "composer.videoSubTypeImageToVideoImageCountRequired": "\u56fe\u751f\u89c6\u9891\u9700\u8981\u6070\u597d\u4e00\u5f20\u53c2\u8003\u56fe\u7247\u3002", - "composer.videoSubTypeFirstLastFrameImageCountRequired": "\u9996\u5c3e\u5e27\u9700\u8981\u6070\u597d\u4e24\u5f20\u53c2\u8003\u56fe\u7247\u3002", + "composer.videoSubTypeImageToVideoImageCountRequired": "\u56fe\u751f\u89c6\u9891\u9700\u8981\u6070\u597d\u4e00\u5f20\u53c2\u8003\u56fe\u7247\uff0c\u4e14\u4e0d\u80fd\u5305\u542b\u97f3\u9891\u6216\u89c6\u9891\u7d20\u6750\u3002", + "composer.videoSubTypeFirstLastFrameImageCountRequired": "\u9996\u5c3e\u5e27\u9700\u8981\u6070\u597d\u4e24\u5f20\u53c2\u8003\u56fe\u7247\uff0c\u4e14\u4e0d\u80fd\u5305\u542b\u97f3\u9891\u6216\u89c6\u9891\u7d20\u6750\u3002", "composer.firstFrame": "\u9996\u5e27", "composer.lastFrame": "\u5c3e\u5e27", "composer.videoSubTypeMultiReference": "图生视频", diff --git a/src/store/execution/__tests__/generateVideoExecutor.test.ts b/src/store/execution/__tests__/generateVideoExecutor.test.ts index 41b18014..9e7f38a7 100644 --- a/src/store/execution/__tests__/generateVideoExecutor.test.ts +++ b/src/store/execution/__tests__/generateVideoExecutor.test.ts @@ -68,6 +68,27 @@ beforeEach(() => { vi.clearAllMocks(); }); +function mockPopiTaskSuccess() { + mockFetch.mockImplementation(async (url: string) => { + if (url === "/api/generate/task/create") { + return { + ok: true, + json: () => Promise.resolve({ success: true, taskId: "task-1" }), + }; + } + if (url.startsWith("/api/generate/task/poll")) { + return { + ok: true, + json: () => Promise.resolve({ + status: "completed", + result: { success: true, video: "data:video/mp4;base64,output" }, + }), + }; + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); +} + async function withProviderMode(mode: "multi" | "popi", run: () => Promise): Promise { const originalNextPublicMode = process.env.NEXT_PUBLIC_PROVIDER_MODE; const originalProviderMode = process.env.PROVIDER_MODE; @@ -394,10 +415,7 @@ describe("executeGenerateVideo", () => { selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" }, parameters: { subType: 202 }, }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }), - }); + mockPopiTaskSuccess(); const ctx = makeCtx(node, { getConnectedInputs: vi.fn().mockReturnValue({ @@ -424,10 +442,7 @@ describe("executeGenerateVideo", () => { selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" }, parameters: { subType: 204 }, }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }), - }); + mockPopiTaskSuccess(); const ctx = makeCtx(node, { getConnectedInputs: vi.fn().mockReturnValue({ @@ -445,15 +460,68 @@ describe("executeGenerateVideo", () => { expect(body.parameters.subType).toBe(204); }); + it("normalizes image-to-video subtype to all-purpose reference when video input exists", async () => { + const node = makeNode({ + selectedModel: { + provider: "popiserver", + modelId: "44", + displayName: "Seedance", + metadata: { isSupportVideos: true }, + }, + parameters: { subType: 202 }, + }); + mockPopiTaskSuccess(); + + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: ["https://static.popi.art/1.png"], + videos: ["https://static.popi.art/input.mp4"], + audio: [], + text: "video prompt", + dynamicInputs: {}, + easeCurve: null, + }), + }); + await executeGenerateVideo(ctx); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.parameters.subType).toBe(203); + }); + + it("normalizes first-last-frame subtype to all-purpose reference when audio input exists", async () => { + const node = makeNode({ + selectedModel: { + provider: "popiserver", + modelId: "44", + displayName: "Seedance", + metadata: { isSupportAudios: true }, + }, + parameters: { subType: 204 }, + }); + mockPopiTaskSuccess(); + + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: ["https://static.popi.art/first.png", "https://static.popi.art/last.png"], + videos: [], + audio: ["https://static.popi.art/input.mp3"], + text: "video prompt", + dynamicInputs: {}, + easeCurve: null, + }), + }); + await executeGenerateVideo(ctx); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.parameters.subType).toBe(203); + }); + it("defaults PopiServer video subtype to image-to-video for exactly one image", async () => { const node = makeNode({ selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" }, parameters: {}, }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }), - }); + mockPopiTaskSuccess(); const ctx = makeCtx(node, { getConnectedInputs: vi.fn().mockReturnValue({ diff --git a/src/store/execution/generateVideoExecutor.ts b/src/store/execution/generateVideoExecutor.ts index bf318721..2ec54d5a 100644 --- a/src/store/execution/generateVideoExecutor.ts +++ b/src/store/execution/generateVideoExecutor.ts @@ -54,16 +54,19 @@ function getGeneratedImageUrl(value: unknown): string | undefined { function normalizeVideoGenerationSubType( parameters: Record, - imageCount: number + imageCount: number, + hasVideoInput: boolean, + hasAudioInput: boolean ): Record { const subType = Number(parameters.subType); + const hasNonImageReference = hasVideoInput || hasAudioInput; if (subType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME) { - return imageCount === 2 + return imageCount === 2 && !hasNonImageReference ? parameters : { ...parameters, subType: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE }; } if (subType === VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO) { - return imageCount === 1 + return imageCount === 1 && !hasNonImageReference ? parameters : { ...parameters, subType: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE }; } @@ -72,7 +75,7 @@ function normalizeVideoGenerationSubType( } return { ...parameters, - subType: imageCount === 1 + subType: imageCount === 1 && !hasNonImageReference ? VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO : VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE, }; @@ -83,6 +86,8 @@ function buildVideoParameters( nodeConfig: ReturnType, modelToUse: SelectedModel, imageCount: number, + hasVideoInput: boolean, + hasAudioInput: boolean, parametersOverride?: Record ): Record | undefined { let parameters = removeFirstClassGenerationParameters(mergeSchemaDefaults( @@ -94,7 +99,7 @@ function buildVideoParameters( const soundParameterName = getVideoSoundParameterName(modelToUse); if (modelToUse.provider === "popiserver") { - parameters = normalizeVideoGenerationSubType(parameters, imageCount); + parameters = normalizeVideoGenerationSubType(parameters, imageCount, hasVideoInput, hasAudioInput); const merged = mergeConfiguredParametersOverride(parameters, { ...(nodeConfig.aspectRatio ? { aspectRatio: nodeConfig.aspectRatio, aspect_ratio: nodeConfig.aspectRatio, ratio: nodeConfig.aspectRatio } @@ -352,7 +357,15 @@ export async function executeGenerateVideo( }) : { images: preparedInputs.images, videos: [], voices: [] }; const parameters = sanitizeReferenceSubjectListParameter( - buildVideoParameters(nodeData, nodeConfig, modelToUse, supportedInputs.images.length, parametersOverride), + buildVideoParameters( + nodeData, + nodeConfig, + modelToUse, + supportedInputs.images.length, + supportedInputs.videos.length > 0, + supportedInputs.voices.length > 0, + parametersOverride + ), nodeData, modelToUse );