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 />);
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(<GenerationComposer />);
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(<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(() => {
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 () => {
useWorkflowStore.setState({
nodes: [
@ -1785,10 +1847,10 @@ describe("GenerationComposer", () => {
});
render(<GenerationComposer />);
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",
},
]);
});

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

@ -26,7 +26,7 @@ describe("ModelParameters", () => {
const { rerender } = render(
<ModelParameters {...defaultProps} isLoading schema={[]} />
);
expect(screen.getByText("Loading parameters...")).toBeInTheDocument();
expect(screen.getByText(/Loading parameters|正在加载参数/)).toBeInTheDocument();
rerender(
<ModelParameters {...defaultProps} error="Failed to load model" schema={[]} />
@ -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(
<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 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<ComposerReferenceInput[]>(() => {
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;

2
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}
/>
</div>
)}

14
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<number | null>(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<string, unknown> = {};
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(() => {

8
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": "图生视频",

92
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<T>(mode: "multi" | "popi", run: () => Promise<T>): Promise<T> {
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({

25
src/store/execution/generateVideoExecutor.ts

@ -54,16 +54,19 @@ function getGeneratedImageUrl(value: unknown): string | undefined {
function normalizeVideoGenerationSubType(
parameters: Record<string, unknown>,
imageCount: number
imageCount: number,
hasVideoInput: boolean,
hasAudioInput: boolean
): Record<string, unknown> {
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<typeof readVideoGenerationConfig>,
modelToUse: SelectedModel,
imageCount: number,
hasVideoInput: boolean,
hasAudioInput: boolean,
parametersOverride?: Record<string, unknown>
): Record<string, unknown> | 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
);

Loading…
Cancel
Save