diff --git a/src/store/execution/__tests__/generateVideoExecutor.test.ts b/src/store/execution/__tests__/generateVideoExecutor.test.ts index 76c4198d..8c544a08 100644 --- a/src/store/execution/__tests__/generateVideoExecutor.test.ts +++ b/src/store/execution/__tests__/generateVideoExecutor.test.ts @@ -347,6 +347,88 @@ describe("executeGenerateVideo", () => { }); }); + it("normalizes stale PopiServer image-to-video subtype to all-purpose reference for multiple images", async () => { + const node = makeNode({ + 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" }), + }); + + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: [ + "https://static.popi.art/1.png", + "https://static.popi.art/2.png", + "https://static.popi.art/3.png", + ], + videos: [], + 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("keeps PopiServer first-last-frame subtype for exactly two images", async () => { + const node = makeNode({ + 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" }), + }); + + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: ["https://static.popi.art/first.png", "https://static.popi.art/last.png"], + videos: [], + 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(204); + }); + + 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" }), + }); + + const ctx = makeCtx(node, { + getConnectedInputs: vi.fn().mockReturnValue({ + images: ["https://static.popi.art/1.png"], + videos: [], + 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(202); + }); + it("uses persisted config over legacy video fields when executing", async () => { const node = makeNode({ selectedModel: { provider: "kie", modelId: "legacy-video", displayName: "Legacy Video" }, diff --git a/src/store/execution/generateVideoExecutor.ts b/src/store/execution/generateVideoExecutor.ts index eef6f3a2..85aa218c 100644 --- a/src/store/execution/generateVideoExecutor.ts +++ b/src/store/execution/generateVideoExecutor.ts @@ -38,6 +38,10 @@ export interface GenerateVideoOptions { useStoredFallback?: boolean; } +const VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO = 202; +const VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE = 203; +const VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME = 204; + function getGeneratedImageUrl(value: unknown): string | undefined { if (typeof value === "string" && value.length > 0) return value; if (!value || typeof value !== "object") return undefined; @@ -46,13 +50,40 @@ function getGeneratedImageUrl(value: unknown): string | undefined { return typeof record.url === "string" && record.url.length > 0 ? record.url : undefined; } +function normalizeVideoGenerationSubType( + parameters: Record, + imageCount: number +): Record { + const subType = Number(parameters.subType); + if (subType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME) { + return imageCount === 2 + ? parameters + : { ...parameters, subType: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE }; + } + if (subType === VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO) { + return imageCount === 1 + ? parameters + : { ...parameters, subType: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE }; + } + if (Number.isFinite(subType)) { + return parameters; + } + return { + ...parameters, + subType: imageCount === 1 + ? VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO + : VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE, + }; +} + function buildVideoParameters( nodeData: GenerateVideoNodeData, nodeConfig: ReturnType, modelToUse: SelectedModel, + imageCount: number, parametersOverride?: Record ): Record | undefined { - const parameters = removeFirstClassGenerationParameters(mergeSchemaDefaults( + let parameters = removeFirstClassGenerationParameters(mergeSchemaDefaults( buildDefaultParametersFromSchema(nodeData.parameterSchema ?? []), parametersOverride ?? nodeConfig.parameters )); @@ -61,6 +92,7 @@ function buildVideoParameters( const soundParameterName = getVideoSoundParameterName(modelToUse); if (modelToUse.provider === "popiserver") { + parameters = normalizeVideoGenerationSubType(parameters, imageCount); const merged = mergeConfiguredParametersOverride(parameters, { ...(nodeConfig.aspectRatio ? { aspectRatio: nodeConfig.aspectRatio, aspect_ratio: nodeConfig.aspectRatio, ratio: nodeConfig.aspectRatio } @@ -283,7 +315,7 @@ export async function executeGenerateVideo( images: preparedInputs.images, prompt: text, selectedModel: modelToUse, - parameters: buildVideoParameters(nodeData, nodeConfig, modelToUse, parametersOverride), + parameters: buildVideoParameters(nodeData, nodeConfig, modelToUse, preparedInputs.images.length, parametersOverride), dynamicInputs: preparedInputs.dynamicInputs, mediaType: "video" as const, };