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
);