Browse Source

修改音频 视频问题

feature/handle
TianYun 1 month ago
parent
commit
31b75cc4c6
  1. 78
      src/components/__tests__/GenerationComposer.test.tsx
  2. 47
      src/components/__tests__/ModelParameters.test.tsx
  3. 31
      src/components/composer/GenerationComposer.tsx
  4. 2
      src/components/nodes/GenerateAudioNode.tsx
  5. 14
      src/components/nodes/ModelParameters.tsx
  6. 8
      src/i18n/index.tsx
  7. 92
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  8. 25
      src/store/execution/generateVideoExecutor.ts

78
src/components/__tests__/GenerationComposer.test.tsx

@ -1692,7 +1692,7 @@ describe("GenerationComposer", () => {
}); });
render(<GenerationComposer />); render(<GenerationComposer />);
fireEvent.click(screen.getByText("First/last frame")); fireEvent.click(screen.getByText("首尾帧"));
await waitFor(() => { await waitFor(() => {
const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData; const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData;
@ -1714,7 +1714,7 @@ describe("GenerationComposer", () => {
}); });
render(<GenerationComposer />); render(<GenerationComposer />);
fireEvent.click(screen.getByText("Image to video")); fireEvent.click(screen.getByText("图生视频"));
await waitFor(() => { await waitFor(() => {
const data = useWorkflowStore.getState().nodes[1].data as GenerateVideoNodeData; const data = useWorkflowStore.getState().nodes[1].data as GenerateVideoNodeData;
@ -1738,7 +1738,38 @@ describe("GenerationComposer", () => {
}); });
render(<GenerationComposer />); render(<GenerationComposer />);
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(<GenerationComposer />);
fireEvent.click(screen.getByText("图生视频"));
await waitFor(() => { await waitFor(() => {
const data = useWorkflowStore.getState().nodes[2].data as GenerateVideoNodeData; 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(<GenerationComposer />);
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 () => { it("labels two first/last frame images without changing their reference order", async () => {
useWorkflowStore.setState({ useWorkflowStore.setState({
nodes: [ nodes: [
@ -1785,10 +1847,10 @@ describe("GenerationComposer", () => {
}); });
render(<GenerationComposer />); render(<GenerationComposer />);
expect(screen.getByText("First")).toBeInTheDocument(); expect(screen.getByText("首帧")).toBeInTheDocument();
expect(screen.getByText("Last")).toBeInTheDocument(); expect(screen.getByText("尾帧")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Generate")); fireEvent.click(screen.getByLabelText(/Generate|生成/));
await waitFor(() => { await waitFor(() => {
expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1"); expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1");
@ -1799,13 +1861,13 @@ describe("GenerationComposer", () => {
id: 1, id: 1,
type: "image", type: "image",
url: "https://static.popi.art/first.jpg", url: "https://static.popi.art/first.jpg",
name: "@图1", name: "图1",
}, },
{ {
id: 2, id: 2,
type: "image", type: "image",
url: "https://static.popi.art/last.jpg", url: "https://static.popi.art/last.jpg",
name: "@图2", name: "图2",
}, },
]); ]);
}); });

47
src/components/__tests__/ModelParameters.test.tsx

@ -26,7 +26,7 @@ describe("ModelParameters", () => {
const { rerender } = render( const { rerender } = render(
<ModelParameters {...defaultProps} isLoading schema={[]} /> <ModelParameters {...defaultProps} isLoading schema={[]} />
); );
expect(screen.getByText("Loading parameters...")).toBeInTheDocument(); expect(screen.getByText(/Loading parameters|正在加载参数/)).toBeInTheDocument();
rerender( rerender(
<ModelParameters {...defaultProps} error="Failed to load model" schema={[]} /> <ModelParameters {...defaultProps} error="Failed to load model" schema={[]} />
@ -170,11 +170,52 @@ describe("ModelParameters", () => {
/> />
); );
expect(screen.getByText("Quality")).toBeInTheDocument(); expect(screen.getByRole("combobox")).toBeInTheDocument();
expect(screen.queryByText("Size")).not.toBeInTheDocument(); expect(screen.queryByText("1024 x 1024")).not.toBeInTheDocument();
expect(onParametersChange).toHaveBeenCalledWith({}); expect(onParametersChange).toHaveBeenCalledWith({});
await waitFor(() => { await waitFor(() => {
expect(onParametersChange).toHaveBeenCalledWith({ quality: "auto" }); expect(onParametersChange).toHaveBeenCalledWith({ quality: "auto" });
}); });
}); });
it("can hide parameters without pruning stored values", async () => {
const onParametersChange = vi.fn();
render(
<ModelParameters
{...defaultProps}
parameters={{ voiceId: "male-qn-qingse" }}
schema={[
createMockParameter({
name: "voiceId",
type: "string",
options: [
{ value: "male-qn-qingse", label: "Male" },
{ value: "female-shaonv", label: "Female" },
],
default: "female-shaonv",
}),
createMockParameter({
name: "quality",
type: "string",
options: [
{ value: "auto", label: "Auto" },
{ value: "high", label: "High" },
],
default: "auto",
}),
]}
onParametersChange={onParametersChange}
hiddenParameterNames={["voiceId"]}
pruneHiddenParameters={false}
/>
);
expect(screen.queryByText("VoiceId")).not.toBeInTheDocument();
await waitFor(() => {
expect(onParametersChange).toHaveBeenCalledWith({
voiceId: "male-qn-qingse",
quality: "auto",
});
});
});
}); });

31
src/components/composer/GenerationComposer.tsx

@ -97,6 +97,11 @@ type VideoStitchLoopCount = 1 | 2 | 3;
type VideoGenerationSubType = 202 | 203 | 204; type VideoGenerationSubType = 202 | 203 | 204;
type ComposerReferenceMaterialType = "image" | "video" | "audio"; type ComposerReferenceMaterialType = "image" | "video" | "audio";
type AssistMenuType = "command" | "reference"; type AssistMenuType = "command" | "reference";
type ReferenceMediaCounts = {
images: number;
videos: number;
audio: number;
};
interface ComposerReferenceMaterial { interface ComposerReferenceMaterial {
id: number; id: number;
@ -247,12 +252,12 @@ function videoGenerationSubTypeFromUnknown(value: unknown): VideoGenerationSubTy
return isVideoGenerationSubType(parsed) ? parsed : null; return isVideoGenerationSubType(parsed) ? parsed : null;
} }
function canUseFirstLastFrameSubType(imageCount: number): boolean { function canUseFirstLastFrameSubType(counts: ReferenceMediaCounts): boolean {
return imageCount === 2; return counts.images === 2 && counts.videos === 0 && counts.audio === 0;
} }
function canUseImageToVideoSubType(imageCount: number): boolean { function canUseImageToVideoSubType(counts: ReferenceMediaCounts): boolean {
return imageCount === 1; return counts.images === 1 && counts.videos === 0 && counts.audio === 0;
} }
function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] { function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] {
@ -1856,8 +1861,13 @@ export function GenerationComposer() {
const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : []; const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : [];
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages; const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages;
const isVideoSubTypeSelectorVisible = context.mode === "node-edit" && context.nodeType === "generateVideo"; const isVideoSubTypeSelectorVisible = context.mode === "node-edit" && context.nodeType === "generateVideo";
const canUseFirstLastFrame = canUseFirstLastFrameSubType(referenceImages.length); const referenceMediaCounts: ReferenceMediaCounts = {
const canUseImageToVideo = canUseImageToVideoSubType(referenceImages.length); 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<ComposerReferenceInput[]>(() => { const referenceImageInputs = useMemo<ComposerReferenceInput[]>(() => {
const imageEdgeIds = const imageEdgeIds =
context.mode === "node-edit" && context.node && connectedReferenceImages.length > 0 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 const submitReferenceImageCount = connectedInputs?.images.length && connectedInputs.images.length > 0
? connectedInputs.images.length ? connectedInputs.images.length
: activeDraft.inputImages.length; : activeDraft.inputImages.length;
const submitReferenceMediaCounts: ReferenceMediaCounts = {
images: submitReferenceImageCount,
videos: connectedInputs?.videos.length ?? 0,
audio: connectedInputs?.audio.length ?? 0,
};
const referenceSubjectList = buildReferenceSubjectListParameter(referenceMaterials); const referenceSubjectList = buildReferenceSubjectListParameter(referenceMaterials);
const shouldWriteVideoSubType = context.mode === "empty-create" || explicitSubmitVideoSubType !== null; const shouldWriteVideoSubType = context.mode === "empty-create" || explicitSubmitVideoSubType !== null;
const shouldAttachReferenceSubjectList = const shouldAttachReferenceSubjectList =
@ -2563,7 +2578,7 @@ export function GenerationComposer() {
if ( if (
submitVideoNodeType === "generateVideo" && submitVideoNodeType === "generateVideo" &&
explicitSubmitVideoSubType === VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE && explicitSubmitVideoSubType === VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE &&
!canUseImageToVideoSubType(submitReferenceImageCount) !canUseImageToVideoSubType(submitReferenceMediaCounts)
) { ) {
message.warning(t("composer.videoSubTypeImageToVideoImageCountRequired")); message.warning(t("composer.videoSubTypeImageToVideoImageCountRequired"));
return; return;
@ -2571,7 +2586,7 @@ export function GenerationComposer() {
if ( if (
submitVideoNodeType === "generateVideo" && submitVideoNodeType === "generateVideo" &&
explicitSubmitVideoSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME && explicitSubmitVideoSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME &&
!canUseFirstLastFrameSubType(submitReferenceImageCount) !canUseFirstLastFrameSubType(submitReferenceMediaCounts)
) { ) {
message.warning(t("composer.videoSubTypeFirstLastFrameImageCountRequired")); message.warning(t("composer.videoSubTypeFirstLastFrameImageCountRequired"));
return; return;

2
src/components/nodes/GenerateAudioNode.tsx

@ -495,6 +495,7 @@ export function GenerateAudioNodeView({
parameters={nodeConfig.parameters || {}} parameters={nodeConfig.parameters || {}}
onParametersChange={handleParametersChange} onParametersChange={handleParametersChange}
hiddenParameterNames={AUDIO_COMPOSER_PARAMETER_NAMES} hiddenParameterNames={AUDIO_COMPOSER_PARAMETER_NAMES}
pruneHiddenParameters={false}
/> />
)} )}
</> </>
@ -714,6 +715,7 @@ export function GenerateAudioNodeView({
onParametersChange={handleParametersChange} onParametersChange={handleParametersChange}
onExpandChange={handleParametersExpandChange} onExpandChange={handleParametersExpandChange}
hiddenParameterNames={AUDIO_COMPOSER_PARAMETER_NAMES} hiddenParameterNames={AUDIO_COMPOSER_PARAMETER_NAMES}
pruneHiddenParameters={false}
/> />
</div> </div>
)} )}

14
src/components/nodes/ModelParameters.tsx

@ -31,6 +31,7 @@ interface ModelParametersProps {
onExpandChange?: (expanded: boolean, parameterCount: number) => void; onExpandChange?: (expanded: boolean, parameterCount: number) => void;
onInputsLoaded?: (inputs: ModelInputDef[]) => void; onInputsLoaded?: (inputs: ModelInputDef[]) => void;
hiddenParameterNames?: string[]; hiddenParameterNames?: string[];
pruneHiddenParameters?: boolean;
} }
/** /**
@ -46,6 +47,7 @@ function ModelParametersInner({
onParametersChange, onParametersChange,
onExpandChange, onExpandChange,
hiddenParameterNames, hiddenParameterNames,
pruneHiddenParameters = true,
}: ModelParametersProps) { }: ModelParametersProps) {
const { t } = useI18n(); const { t } = useI18n();
const lastExpandCountRef = useRef<number | null>(null); const lastExpandCountRef = useRef<number | null>(null);
@ -62,7 +64,7 @@ function ModelParametersInner({
// Remove parameters controlled by a higher-priority surface, such as the bottom composer. // Remove parameters controlled by a higher-priority surface, such as the bottom composer.
useEffect(() => { useEffect(() => {
if (hiddenParameterSet.size === 0) return; if (!pruneHiddenParameters || hiddenParameterSet.size === 0) return;
const nextParameters = { ...parameters }; const nextParameters = { ...parameters };
let changed = false; let changed = false;
for (const name of hiddenParameterSet) { for (const name of hiddenParameterSet) {
@ -74,14 +76,16 @@ function ModelParametersInner({
if (changed) { if (changed) {
onParametersChange(nextParameters); onParametersChange(nextParameters);
} }
}, [hiddenParameterSet, onParametersChange, parameters]); }, [hiddenParameterSet, onParametersChange, parameters, pruneHiddenParameters]);
// Pre-populate visible schema defaults into parameters // Pre-populate visible schema defaults into parameters
useEffect(() => { useEffect(() => {
if (visibleSchema.length === 0) return; if (visibleSchema.length === 0) return;
const baseParameters = { ...parameters }; const baseParameters = { ...parameters };
for (const name of hiddenParameterSet) { if (pruneHiddenParameters) {
delete baseParameters[name]; for (const name of hiddenParameterSet) {
delete baseParameters[name];
}
} }
const defaults: Record<string, unknown> = {}; const defaults: Record<string, unknown> = {};
let hasNewDefaults = false; let hasNewDefaults = false;
@ -101,7 +105,7 @@ function ModelParametersInner({
if (hasNewDefaults) { if (hasNewDefaults) {
onParametersChange({ ...baseParameters, ...defaults }); onParametersChange({ ...baseParameters, ...defaults });
} }
}, [visibleSchema, parameters, onParametersChange]); }, [visibleSchema, parameters, onParametersChange, hiddenParameterSet, pruneHiddenParameters]);
// Notify parent to resize node when schema loads // Notify parent to resize node when schema loads
useEffect(() => { useEffect(() => {

8
src/i18n/index.tsx

@ -708,8 +708,8 @@ const en = {
"composer.videoSubTypeTextToVideo": "All-purpose reference", "composer.videoSubTypeTextToVideo": "All-purpose reference",
"composer.videoSubTypeFirstLastFrame": "First/last frame", "composer.videoSubTypeFirstLastFrame": "First/last frame",
"composer.videoSubTypeImageRequired": "Image to video or first/last frame requires at least one reference image.", "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.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.", "composer.videoSubTypeFirstLastFrameImageCountRequired": "First/last frame requires exactly two reference images and no audio or video assets.",
"composer.firstFrame": "First", "composer.firstFrame": "First",
"composer.lastFrame": "Last", "composer.lastFrame": "Last",
"composer.commands": "Commands", "composer.commands": "Commands",
@ -1854,8 +1854,8 @@ Object.assign(zhCN, {
"group.colorPurple": "紫色", "group.colorPurple": "紫色",
"group.colorOrange": "橙色", "group.colorOrange": "橙色",
"group.colorRed": "红色", "group.colorRed": "红色",
"composer.videoSubTypeImageToVideoImageCountRequired": "\u56fe\u751f\u89c6\u9891\u9700\u8981\u6070\u597d\u4e00\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\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.firstFrame": "\u9996\u5e27",
"composer.lastFrame": "\u5c3e\u5e27", "composer.lastFrame": "\u5c3e\u5e27",
"composer.videoSubTypeMultiReference": "图生视频", "composer.videoSubTypeMultiReference": "图生视频",

92
src/store/execution/__tests__/generateVideoExecutor.test.ts

@ -68,6 +68,27 @@ beforeEach(() => {
vi.clearAllMocks(); 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<T>(mode: "multi" | "popi", run: () => Promise<T>): Promise<T> { async function withProviderMode<T>(mode: "multi" | "popi", run: () => Promise<T>): Promise<T> {
const originalNextPublicMode = process.env.NEXT_PUBLIC_PROVIDER_MODE; const originalNextPublicMode = process.env.NEXT_PUBLIC_PROVIDER_MODE;
const originalProviderMode = process.env.PROVIDER_MODE; const originalProviderMode = process.env.PROVIDER_MODE;
@ -394,10 +415,7 @@ describe("executeGenerateVideo", () => {
selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" }, selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" },
parameters: { subType: 202 }, parameters: { subType: 202 },
}); });
mockFetch.mockResolvedValueOnce({ mockPopiTaskSuccess();
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node, { const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({ getConnectedInputs: vi.fn().mockReturnValue({
@ -424,10 +442,7 @@ describe("executeGenerateVideo", () => {
selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" }, selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" },
parameters: { subType: 204 }, parameters: { subType: 204 },
}); });
mockFetch.mockResolvedValueOnce({ mockPopiTaskSuccess();
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node, { const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({ getConnectedInputs: vi.fn().mockReturnValue({
@ -445,15 +460,68 @@ describe("executeGenerateVideo", () => {
expect(body.parameters.subType).toBe(204); 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 () => { it("defaults PopiServer video subtype to image-to-video for exactly one image", async () => {
const node = makeNode({ const node = makeNode({
selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" }, selectedModel: { provider: "popiserver", modelId: "44", displayName: "Seedance" },
parameters: {}, parameters: {},
}); });
mockFetch.mockResolvedValueOnce({ mockPopiTaskSuccess();
ok: true,
json: () => Promise.resolve({ success: true, video: "data:video/mp4;base64,output" }),
});
const ctx = makeCtx(node, { const ctx = makeCtx(node, {
getConnectedInputs: vi.fn().mockReturnValue({ getConnectedInputs: vi.fn().mockReturnValue({

25
src/store/execution/generateVideoExecutor.ts

@ -54,16 +54,19 @@ function getGeneratedImageUrl(value: unknown): string | undefined {
function normalizeVideoGenerationSubType( function normalizeVideoGenerationSubType(
parameters: Record<string, unknown>, parameters: Record<string, unknown>,
imageCount: number imageCount: number,
hasVideoInput: boolean,
hasAudioInput: boolean
): Record<string, unknown> { ): Record<string, unknown> {
const subType = Number(parameters.subType); const subType = Number(parameters.subType);
const hasNonImageReference = hasVideoInput || hasAudioInput;
if (subType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME) { if (subType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME) {
return imageCount === 2 return imageCount === 2 && !hasNonImageReference
? parameters ? parameters
: { ...parameters, subType: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE }; : { ...parameters, subType: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE };
} }
if (subType === VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO) { if (subType === VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO) {
return imageCount === 1 return imageCount === 1 && !hasNonImageReference
? parameters ? parameters
: { ...parameters, subType: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE }; : { ...parameters, subType: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE };
} }
@ -72,7 +75,7 @@ function normalizeVideoGenerationSubType(
} }
return { return {
...parameters, ...parameters,
subType: imageCount === 1 subType: imageCount === 1 && !hasNonImageReference
? VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO ? VIDEO_GENERATION_SUB_TYPE_IMAGE_TO_VIDEO
: VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE, : VIDEO_GENERATION_SUB_TYPE_ALL_PURPOSE_REFERENCE,
}; };
@ -83,6 +86,8 @@ function buildVideoParameters(
nodeConfig: ReturnType<typeof readVideoGenerationConfig>, nodeConfig: ReturnType<typeof readVideoGenerationConfig>,
modelToUse: SelectedModel, modelToUse: SelectedModel,
imageCount: number, imageCount: number,
hasVideoInput: boolean,
hasAudioInput: boolean,
parametersOverride?: Record<string, unknown> parametersOverride?: Record<string, unknown>
): Record<string, unknown> | undefined { ): Record<string, unknown> | undefined {
let parameters = removeFirstClassGenerationParameters(mergeSchemaDefaults( let parameters = removeFirstClassGenerationParameters(mergeSchemaDefaults(
@ -94,7 +99,7 @@ function buildVideoParameters(
const soundParameterName = getVideoSoundParameterName(modelToUse); const soundParameterName = getVideoSoundParameterName(modelToUse);
if (modelToUse.provider === "popiserver") { if (modelToUse.provider === "popiserver") {
parameters = normalizeVideoGenerationSubType(parameters, imageCount); parameters = normalizeVideoGenerationSubType(parameters, imageCount, hasVideoInput, hasAudioInput);
const merged = mergeConfiguredParametersOverride(parameters, { const merged = mergeConfiguredParametersOverride(parameters, {
...(nodeConfig.aspectRatio ...(nodeConfig.aspectRatio
? { aspectRatio: nodeConfig.aspectRatio, aspect_ratio: nodeConfig.aspectRatio, ratio: 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: [] }; : { images: preparedInputs.images, videos: [], voices: [] };
const parameters = sanitizeReferenceSubjectListParameter( 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, nodeData,
modelToUse modelToUse
); );

Loading…
Cancel
Save