Browse Source

接入 全能参考 首尾帧 图生视频

video_node
TianYun 1 month ago
parent
commit
febb5b9ffb
  1. 110
      src/app/api/generate/providers/__tests__/popiserver.test.ts
  2. 98
      src/app/api/generate/providers/popiserver.ts
  3. 153
      src/components/__tests__/GenerationComposer.test.tsx
  4. 461
      src/components/composer/GenerationComposer.tsx
  5. 17
      src/i18n/index.tsx

110
src/app/api/generate/providers/__tests__/popiserver.test.ts

@ -202,6 +202,7 @@ describe("popiserver generation provider", () => {
model: "",
origin: "canvas",
aiPlatform: "GATEWAY",
aiModelName: "Speech 2.8 HD",
aiModelId: 30,
aiModelCode: "speech-2.8-hd",
aiModelCodeAlias: "speech-2.8-hd",
@ -358,6 +359,115 @@ describe("popiserver generation provider", () => {
]);
});
it("builds referenceSubjectList from final image and video URLs", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve({
status: "0000",
message: "ok",
data: { id: 1194 },
}),
} as Response);
const input = makeInput();
input.model = {
...input.model,
id: "29",
name: "seedance 2.0-fast",
metadata: {
type: 2,
subType: 203,
aiModelId: 29,
aiModelCode: "seedance 2.0",
aiModelCodeAlias: "doubao-seedance-2-0-fast-260128",
},
};
input.prompt = "@图1的人物参考@视频2的人物的动作跳舞";
input.images = ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"];
input.dynamicInputs = {
videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"],
audios: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"],
};
input.parameters = {
subType: 203,
aiModelName: "seedance 2.0-fast",
aiPlatform: "seedance 2.0",
model: "seedance 2.0",
ratio: "16:9",
resolution: "480P",
duration: 5,
batchSize: 1,
referenceSubjectList: [
{
id: 1,
type: "image",
url: "stale-image-url",
name: "@图1",
},
{
id: 2,
type: "video",
url: "stale-video-url",
name: "@视频2",
duration: 5.033,
},
{
id: 3,
type: "audio",
url: "stale-audio-url",
name: "@音频3",
},
],
};
const result = await submitPopiTask(makeRequest(), "login-token", input);
expect(result.taskId).toBe("1194");
const [, init] = vi.mocked(global.fetch).mock.calls[0];
const body = JSON.parse(String(init?.body));
expect(body).toMatchObject({
subType: 203,
aiModelName: "seedance 2.0-fast",
aiPlatform: "seedance 2.0",
aiModelId: 29,
model: "seedance 2.0",
aiModelCode: "seedance 2.0",
aiModelCodeAlias: "doubao-seedance-2-0-fast-260128",
type: 2,
ratio: "16:9",
resolution: "480P",
duration: 5,
batchSize: 1,
width: 1280,
height: 720,
chatPrompt: "@图1的人物参考@视频2的人物的动作跳舞",
images: ["https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg"],
videos: ["https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4"],
audios: ["https://statictest.popi.art/media/audio/2026/0603/5470.mp3"],
referenceSubjectList: [
{
id: 1,
type: "image",
url: "https://statictest.popi.art/media/ani/character/2026/0512/4540.jpg",
name: "@图1",
},
{
id: 2,
type: "video",
url: "https://statictest.popi.art/media/aiGen/2026/0603/5470.mp4",
name: "@视频2",
duration: 5.033,
},
{
id: 3,
type: "audio",
url: "https://statictest.popi.art/media/audio/2026/0603/5470.mp3",
name: "@音频3",
},
],
});
});
it("extracts completed image task output from task list", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,

98
src/app/api/generate/providers/popiserver.ts

@ -102,6 +102,14 @@ type PopiTask = {
resultList?: PopiTaskResult[] | null;
};
type ReferenceSubject = {
id: number;
type: "image" | "video" | "audio";
url: string;
name: string;
duration?: number;
};
export type PopiTaskPollResult =
| { status: "processing" }
| { status: "failed"; error: string }
@ -422,6 +430,87 @@ function buildPopiChatPrompt(
].join("\n");
}
function referenceSubjectMetadata(parameters: Record<string, unknown>): ReferenceSubject[] {
const value = parameters.referenceSubjectList;
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
const subject = item as Record<string, unknown>;
const type =
subject.type === "video"
? "video"
: subject.type === "image"
? "image"
: subject.type === "audio"
? "audio"
: null;
if (!type) return [];
const id = asNumber(subject.id);
const name = asString(subject.name);
const url = asString(subject.url);
const duration = asNumber(subject.duration);
return [{
id: id ?? 0,
type,
url: url ?? "",
name: name ?? "",
...(duration !== null ? { duration } : {}),
}];
});
}
function buildReferenceSubjectList(
images: string[],
videos: string[],
audios: string[],
parameters: Record<string, unknown>
): ReferenceSubject[] {
const metadata = referenceSubjectMetadata(parameters);
const metadataByType = {
image: metadata.filter((item) => item.type === "image"),
video: metadata.filter((item) => item.type === "video"),
audio: metadata.filter((item) => item.type === "audio"),
};
const subjects: ReferenceSubject[] = [];
images.forEach((url, index) => {
const id = subjects.length + 1;
const meta = metadataByType.image[index];
subjects.push({
id,
type: "image",
url,
name: meta?.name || `@图${id}`,
});
});
videos.forEach((url, index) => {
const id = subjects.length + 1;
const meta = metadataByType.video[index];
subjects.push({
id,
type: "video",
url,
name: meta?.name || `@视频${id}`,
...(meta?.duration ? { duration: meta.duration } : {}),
});
});
audios.forEach((url, index) => {
const id = subjects.length + 1;
const meta = metadataByType.audio[index];
subjects.push({
id,
type: "audio",
url,
name: meta?.name || `@音频${id}`,
});
});
return subjects;
}
function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | null): Record<string, unknown> {
const parameters = input.parameters || {};
const type = asNumber(parameters.type) ?? inferTaskType(input);
@ -446,6 +535,9 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu
];
const videos = collectDynamicMedia(input.dynamicInputs, POPISERVER_VIDEO_DYNAMIC_KEYS);
const audios = collectDynamicMedia(input.dynamicInputs, POPISERVER_AUDIO_DYNAMIC_KEYS);
const referenceSubjectList = type === 2
? buildReferenceSubjectList([...new Set(images)], [...new Set(videos)], [...new Set(audios)], parameters)
: [];
const voiceId =
firstDynamicString(input.dynamicInputs, POPISERVER_VOICE_DYNAMIC_KEYS) ??
asString(parameters.voiceId) ??
@ -454,9 +546,10 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu
const commonBody = {
type,
chatPrompt: buildPopiChatPrompt(input.prompt, parameters, aspectRatio, modelCode, modelAlias),
model: "",
model: asString(parameters.model) ?? "",
origin: "canvas",
aiPlatform: "GATEWAY",
aiPlatform: asString(parameters.aiPlatform) ?? "GATEWAY",
aiModelName: asString(parameters.aiModelName) ?? input.model.name ?? modelDetail?.name ?? modelCode,
aiModelId,
aiModelCode: modelCode,
aiModelCodeAlias: modelAlias,
@ -479,6 +572,7 @@ function buildPopiTaskBody(input: GenerationInput, modelDetail: PopiAIModel | nu
...(images.length > 0 ? { images: [...new Set(images)] } : {}),
...(videos.length > 0 ? { videos: [...new Set(videos)] } : {}),
...(audios.length > 0 ? { audios: [...new Set(audios)] } : {}),
...(referenceSubjectList.length > 0 ? { referenceSubjectList } : {}),
styleId: asNumber(parameters.styleId) ?? 0,
width: asNumber(parameters.width) ?? dimensions.width,
height: asNumber(parameters.height) ?? dimensions.height,

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

@ -308,6 +308,17 @@ function textEdge(source: string, target: string): WorkflowEdge {
} as WorkflowEdge;
}
function imageEdge(source: string, target: string, index: number, targetHandle = `image-${index}`): WorkflowEdge {
return {
id: `edge-${source}-${target}-image-${index}`,
source,
sourceHandle: "image",
target,
targetHandle,
data: { createdAt: index },
} as WorkflowEdge;
}
function videoEdge(source: string, target: string, index: number, targetHandle = `video-${index}`): WorkflowEdge {
return {
id: `edge-${source}-${target}-video-${index}`,
@ -319,6 +330,17 @@ function videoEdge(source: string, target: string, index: number, targetHandle =
} as WorkflowEdge;
}
function audioEdge(source: string, target: string, index: number, targetHandle = `audio-${index}`): WorkflowEdge {
return {
id: `edge-${source}-${target}-audio-${index}`,
source,
sourceHandle: "audio",
target,
targetHandle,
data: { createdAt: index },
} as WorkflowEdge;
}
describe("GenerationComposer", () => {
beforeEach(() => {
vi.clearAllMocks();
@ -1025,6 +1047,137 @@ describe("GenerationComposer", () => {
expect(data.soundEnabled).toBe(false);
});
it("shows connected image, video, and audio materials in the @ menu", async () => {
useWorkflowStore.setState({
nodes: [
imageNode("img-ref", false, { outputImage: "https://static.popi.art/ref.jpg" }),
videoNode("vid-ref", false, {
outputVideo: "https://static.popi.art/ref.mp4",
durationSeconds: 5.033,
}),
audioNode("aud-ref", false, {
outputAudio: "https://static.popi.art/ref.mp3",
}),
videoNode("vid-1", true, {
inputPrompt: "make it",
}),
],
edges: [
imageEdge("img-ref", "vid-1", 0),
videoEdge("vid-ref", "vid-1", 1, "video"),
audioEdge("aud-ref", "vid-1", 2, "audio"),
],
});
render(<GenerationComposer />);
const prompt = screen.getByPlaceholderText(/Describe what you want to generate/) as HTMLTextAreaElement;
fireEvent.change(prompt, { target: { value: "make it @" } });
await waitFor(() => {
expect(screen.getByText("图1")).toBeInTheDocument();
expect(screen.getByText("视频2")).toBeInTheDocument();
expect(screen.getByText("音频3")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("音频3"));
expect(prompt).toHaveValue("make it @音频3 ");
fireEvent.click(screen.getByLabelText("Generate"));
await waitFor(() => {
expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1");
});
const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData;
expect(data.parameters?.referenceSubjectList).toEqual([
{
id: 1,
type: "image",
url: "https://static.popi.art/ref.jpg",
name: "@图1",
},
{
id: 2,
type: "video",
url: "https://static.popi.art/ref.mp4",
name: "@视频2",
duration: 5.033,
},
{
id: 3,
type: "audio",
url: "https://static.popi.art/ref.mp3",
name: "@音频3",
},
]);
});
it("does not allow first/last frame mode unless exactly two images are 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" }),
imageNode("img-c", false, { outputImage: "https://static.popi.art/c.jpg" }),
videoNode("vid-1", true, {
parameters: {},
}),
],
edges: [
imageEdge("img-a", "vid-1", 0),
imageEdge("img-b", "vid-1", 1),
imageEdge("img-c", "vid-1", 2),
],
});
render(<GenerationComposer />);
fireEvent.click(screen.getByText("First/last frame"));
await waitFor(() => {
const data = useWorkflowStore.getState().nodes[3].data as GenerateVideoNodeData;
expect(data.parameters?.subType).not.toBe(204);
});
});
it("labels two first/last frame images without changing their reference order", async () => {
useWorkflowStore.setState({
nodes: [
imageNode("img-first", false, { outputImage: "https://static.popi.art/first.jpg" }),
imageNode("img-last", false, { outputImage: "https://static.popi.art/last.jpg" }),
videoNode("vid-1", true, {
inputPrompt: "animate",
parameters: { subType: 204 },
}),
],
edges: [
imageEdge("img-first", "vid-1", 0),
imageEdge("img-last", "vid-1", 1),
],
});
render(<GenerationComposer />);
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Last")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Generate"));
await waitFor(() => {
expect(useWorkflowStore.getState().regenerateNode).toHaveBeenCalledWith("vid-1");
});
const data = useWorkflowStore.getState().nodes[2].data as GenerateVideoNodeData;
expect(data.parameters?.referenceSubjectList).toEqual([
{
id: 1,
type: "image",
url: "https://static.popi.art/first.jpg",
name: "@图1",
},
{
id: 2,
type: "image",
url: "https://static.popi.art/last.jpg",
name: "@图2",
},
]);
});
it("writes selected video quick settings to the node immediately", () => {
useWorkflowStore.setState({
nodes: [videoNode("vid-1", true, {

461
src/components/composer/GenerationComposer.tsx

@ -13,6 +13,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react";
import { message, Segmented } from "antd";
import { useProviderApiKeys, useWorkflowStore } from "@/store/workflowStore";
import { useModelStore } from "@/store/modelStore";
import {
@ -79,6 +80,17 @@ type EditableNodeType = "nanoBanana" | "generateVideo" | "generateAudio";
type DraftField = GenerationConfigField;
type ComposerDraft = GenerationNodeConfig;
type VideoStitchLoopCount = 1 | 2 | 3;
type VideoGenerationSubType = 202 | 203 | 204;
type ComposerReferenceMaterialType = "image" | "video" | "audio";
interface ComposerReferenceMaterial {
id: number;
type: ComposerReferenceMaterialType;
url: string;
name: string;
duration?: number;
removableIndex?: number;
}
interface ComposerContext {
mode: ComposerMode;
@ -127,6 +139,9 @@ const COMPOSER_COLLAPSED_WIDTH = 560;
const COMPOSER_EXPANDED_HEIGHT_ESTIMATE = 300;
const COMPOSER_COLLAPSED_HEIGHT_ESTIMATE = 80;
const DEFAULT_IMAGE_COUNT_OPTIONS: ImageGenerationCount[] = [1, 2, 4];
const VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE = 202;
const VIDEO_GENERATION_SUB_TYPE_TEXT_TO_VIDEO = 203;
const VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME = 204;
interface SchemaSelectOption {
key: string;
@ -161,6 +176,23 @@ function getSchemaParameterDefault(schema: ModelParameter[] | undefined, name: s
return param?.default ?? param?.options?.[0]?.value ?? param?.enum?.[0];
}
function isVideoGenerationSubType(value: unknown): value is VideoGenerationSubType {
return (
value === VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE ||
value === VIDEO_GENERATION_SUB_TYPE_TEXT_TO_VIDEO ||
value === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME
);
}
function videoGenerationSubTypeFromUnknown(value: unknown): VideoGenerationSubType | null {
const parsed = finiteIntegerFromUnknown(value);
return isVideoGenerationSubType(parsed) ? parsed : null;
}
function canUseFirstLastFrameSubType(imageCount: number): boolean {
return imageCount === 2;
}
function getSchemaSelectOptions(param: ModelParameter): SchemaSelectOption[] {
return param.options?.map((option, index) => ({
key: String(option.value ?? index),
@ -543,6 +575,74 @@ function getConnectedVideoDurationSeconds(
return getNodeVideoDurationSeconds(nodes.find((node) => node.id === videoEdge.source));
}
function getConnectedVideoDurationList(
targetNodeId: string,
nodes: WorkflowNode[],
edges: Array<{ source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null }>
): number[] {
return edges
.filter((edge) =>
edge.target === targetNodeId &&
(isVideoHandle(edge.targetHandle) || isVideoHandle(edge.sourceHandle))
)
.map((edge) => getNodeVideoDurationSeconds(nodes.find((node) => node.id === edge.source)));
}
function buildComposerReferenceMaterials(
images: string[],
videos: string[],
audio: string[],
videoDurations: number[] = [],
removableImages = false
): ComposerReferenceMaterial[] {
const materials: ComposerReferenceMaterial[] = [];
images.forEach((url, index) => {
const id = materials.length + 1;
materials.push({
id,
type: "image",
url,
name: `@图${id}`,
...(removableImages ? { removableIndex: index } : {}),
});
});
videos.forEach((url, index) => {
const id = materials.length + 1;
const duration = videoDurations[index];
materials.push({
id,
type: "video",
url,
name: `@视频${id}`,
...(duration && duration > 0 ? { duration } : {}),
});
});
audio.forEach((url) => {
const id = materials.length + 1;
materials.push({
id,
type: "audio",
url,
name: `@音频${id}`,
});
});
return materials;
}
function buildReferenceSubjectListParameter(materials: ComposerReferenceMaterial[]): Array<Record<string, string | number>> {
return materials.map((material) => ({
id: material.id,
type: material.type,
url: material.url,
name: material.name,
...(material.duration ? { duration: material.duration } : {}),
}));
}
function hasAnyCapability(model: SelectedModel, capabilities: string[]): boolean {
return Boolean(model.capabilities?.some((capability) => capabilities.includes(capability)));
}
@ -927,6 +1027,11 @@ export function GenerationComposer() {
return getConnectedVideoDurationSeconds(context.node.id, nodes, edges);
}, [context, edges, nodes]);
const connectedVideoDurations = useMemo(() => {
if (context.mode !== "node-edit" || !context.node) return [];
return getConnectedVideoDurationList(context.node.id, nodes, edges);
}, [context, edges, nodes]);
const estimatedReferenceImageCount = context.mode === "node-edit" && (connectedInputs?.images.length ?? 0) > 0
? connectedInputs?.images.length ?? 0
: draft.inputImages.length;
@ -1265,12 +1370,16 @@ export function GenerationComposer() {
const hasReferenceImageForSubmit =
(context.mode === "empty-create" || context.mode === "node-edit") &&
((connectedInputs?.images.length ?? 0) > 0 || draft.inputImages.length > 0);
const hasReferenceVideoForSubmit =
context.mode === "node-edit" && (connectedInputs?.videos.length ?? 0) > 0;
const hasReferenceAudioForSubmit =
context.mode === "node-edit" && (connectedInputs?.audio.length ?? 0) > 0;
const submitNodeType = context.mode === "empty-create"
? inferNodeTypeFromModel(draft.selectedModel)
: context.nodeType;
const hasRequiredPromptOrReference = submitNodeType === "generateAudio"
? promptForSubmit.length > 0
: (promptForSubmit.length > 0 || hasReferenceImageForSubmit);
: (promptForSubmit.length > 0 || hasReferenceImageForSubmit || hasReferenceVideoForSubmit || hasReferenceAudioForSubmit);
const canCreateFromSelectedImageInput =
context.mode === "unsupported-node" &&
Boolean(selectedImageInputImage) &&
@ -1298,9 +1407,21 @@ export function GenerationComposer() {
canCreateFromSelectedImageInput;
const connectedReferenceImages = context.mode === "node-edit" ? connectedInputs?.images ?? [] : [];
const connectedReferenceVideos = context.mode === "node-edit" ? connectedInputs?.videos ?? [] : [];
const connectedReferenceAudio = context.mode === "node-edit" ? connectedInputs?.audio ?? [] : [];
const referenceImages = connectedReferenceImages.length > 0 ? connectedReferenceImages : draft.inputImages;
const canUseFirstLastFrame = canUseFirstLastFrameSubType(referenceImages.length);
const referenceMaterials = useMemo(() => buildComposerReferenceMaterials(
referenceImages,
connectedReferenceVideos,
connectedReferenceAudio,
connectedVideoDurations,
connectedReferenceImages.length === 0
), [connectedReferenceAudio, connectedReferenceImages.length, connectedReferenceVideos, connectedVideoDurations, referenceImages]);
const previewReferenceImage =
referencePreviewIndex === null ? null : referenceImages[referencePreviewIndex] ?? null;
referencePreviewIndex === null ? null : referenceMaterials[referencePreviewIndex]?.type === "image"
? referenceMaterials[referencePreviewIndex]?.url ?? null
: null;
const canEditReferenceImages =
(context.mode === "empty-create" || context.mode === "node-edit") &&
connectedReferenceImages.length === 0;
@ -1501,6 +1622,37 @@ export function GenerationComposer() {
[updateNodeData]
);
const isVideoSubTypeSelectorVisible = context.mode === "node-edit" && context.nodeType === "generateVideo";
const currentVideoSubType = videoGenerationSubTypeFromUnknown(draft.parameters?.subType);
const selectedVideoSubType = currentVideoSubType ?? VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE;
const isFirstLastFrameSelected =
selectedVideoSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME && canUseFirstLastFrame;
const videoSubTypeOptions = useMemo(
() => [
{
label: t("composer.videoSubTypeMultiReference"),
value: VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE,
},
{
label: t("composer.videoSubTypeFirstLastFrame"),
value: VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME,
disabled: !canUseFirstLastFrame,
},
{
label: t("composer.videoSubTypeTextToVideo"),
value: VIDEO_GENERATION_SUB_TYPE_TEXT_TO_VIDEO,
},
],
[canUseFirstLastFrame, t]
);
useEffect(() => {
if (!isVideoSubTypeSelectorVisible) return;
if (currentVideoSubType !== VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME) return;
if (canUseFirstLastFrame) return;
updateSchemaDraftParameter("subType", VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE);
}, [canUseFirstLastFrame, currentVideoSubType, isVideoSubTypeSelectorVisible, updateSchemaDraftParameter]);
useEffect(() => {
if (
isProcessNode ||
@ -1706,10 +1858,10 @@ export function GenerationComposer() {
);
useEffect(() => {
if (referencePreviewIndex !== null && referencePreviewIndex >= referenceImages.length) {
if (referencePreviewIndex !== null && referencePreviewIndex >= referenceMaterials.length) {
setReferencePreviewIndex(null);
}
}, [referenceImages.length, referencePreviewIndex]);
}, [referenceMaterials.length, referencePreviewIndex]);
useEffect(() => {
if (!previewReferenceImage) return;
@ -1741,6 +1893,43 @@ export function GenerationComposer() {
if (!canSubmit) return;
const fallbackPrompt = buildReferenceOnlyPrompt();
const submitPrompt = promptForSubmit || fallbackPrompt;
const activeDraft = draftRef.current;
const submitVideoNodeType = context.mode === "empty-create"
? inferNodeTypeFromModel(activeDraft.selectedModel)
: context.nodeType;
const explicitSubmitVideoSubType = videoGenerationSubTypeFromUnknown(activeDraft.parameters?.subType);
const submitVideoSubType = explicitSubmitVideoSubType ?? VIDEO_GENERATION_SUB_TYPE_MULTI_REFERENCE;
const hasSubmitReferenceImage =
(connectedInputs?.images.length ?? 0) > 0 || activeDraft.inputImages.length > 0;
const submitReferenceImageCount = connectedInputs?.images.length && connectedInputs.images.length > 0
? connectedInputs.images.length
: activeDraft.inputImages.length;
const referenceSubjectList = buildReferenceSubjectListParameter(referenceMaterials);
const shouldWriteVideoSubType = context.mode === "empty-create" || explicitSubmitVideoSubType !== null;
const videoParameters = submitVideoNodeType === "generateVideo"
? {
...activeDraft.parameters,
...(shouldWriteVideoSubType ? { subType: submitVideoSubType } : {}),
...(referenceSubjectList.length > 0 ? { referenceSubjectList } : {}),
}
: activeDraft.parameters;
if (
submitVideoNodeType === "generateVideo" &&
explicitSubmitVideoSubType !== null &&
explicitSubmitVideoSubType !== VIDEO_GENERATION_SUB_TYPE_TEXT_TO_VIDEO &&
!hasSubmitReferenceImage
) {
message.warning(t("composer.videoSubTypeImageRequired"));
return;
}
if (
submitVideoNodeType === "generateVideo" &&
explicitSubmitVideoSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME &&
!canUseFirstLastFrameSubType(submitReferenceImageCount)
) {
message.warning(t("composer.videoSubTypeFirstLastFrameImageCountRequired"));
return;
}
if (context.mode === "unsupported-node" && context.node?.type === "imageInput") {
const imageData = context.node.data as ImageInputNodeData;
@ -1767,14 +1956,15 @@ export function GenerationComposer() {
}
if (context.mode === "empty-create") {
const nodeType = inferNodeTypeFromModel(draftRef.current.selectedModel);
const nodeType = submitVideoNodeType;
if (!nodeType) return;
const nextNodeId = addNode(
nodeType,
buildTargetPosition(),
buildInitialDataForNode(nodeType, {
...draftRef.current,
...activeDraft,
parameters: nodeType === "generateVideo" ? videoParameters : activeDraft.parameters,
prompt: submitPrompt,
})
);
@ -1786,6 +1976,18 @@ export function GenerationComposer() {
}
if (context.mode === "node-edit" && context.node) {
if (submitVideoNodeType === "generateVideo") {
const nextDraft = {
...draftRef.current,
parameters: videoParameters,
};
const nextDirtyFields = new Set(dirtyFieldsRef.current);
nextDirtyFields.add("parameters");
draftRef.current = nextDraft;
dirtyFieldsRef.current = nextDirtyFields;
setDraft(nextDraft);
setDirtyFields(nextDirtyFields);
}
if (!promptForSubmit && !promptReadOnly) {
const nextDraft = { ...draftRef.current, prompt: submitPrompt };
const nextDirtyFields = new Set(dirtyFieldsRef.current);
@ -1811,12 +2013,15 @@ export function GenerationComposer() {
buildTargetPosition,
canSubmit,
clearDirtyFields,
connectedInputs,
context,
flushCurrentDraft,
promptForSubmit,
promptReadOnly,
referenceMaterials,
regenerateNode,
selectSingleNode,
t,
]);
const insertCommand = useCallback((value: string) => {
@ -1995,13 +2200,31 @@ export function GenerationComposer() {
</div>
) : (
<>
<div className="flex items-center gap-2 px-4 pt-4">
<div className="mr-1 flex h-14 min-w-24 flex-col justify-center rounded-lg border border-neutral-700 bg-neutral-900/60 px-3">
<span className="text-[10px] text-neutral-500">{t("composer.console")}</span>
<span className="text-xs font-medium text-neutral-100">{modeLabel}</span>
</div>
<div className="px-4 pt-4">
{isVideoSubTypeSelectorVisible && (
<Segmented
options={videoSubTypeOptions}
value={selectedVideoSubType}
onChange={(value) => {
const nextSubType = videoGenerationSubTypeFromUnknown(value);
if (!nextSubType) return;
if (nextSubType === VIDEO_GENERATION_SUB_TYPE_FIRST_LAST_FRAME && !canUseFirstLastFrame) {
message.warning(t("composer.videoSubTypeFirstLastFrameImageCountRequired"));
return;
}
updateSchemaDraftParameter("subType", nextSubType);
}}
className="nodrag nopan nowheel max-w-full text-xs"
/>
)}
{isProcessNode ? (
<div className="mt-2 flex items-start gap-2">
<div className="mr-1 flex h-14 min-w-24 flex-col justify-center rounded-lg border border-neutral-700 bg-neutral-900/60 px-3">
<span className="text-[10px] text-neutral-500">{t("composer.console")}</span>
<span className="text-xs font-medium text-neutral-100">{modeLabel}</span>
</div>
{isProcessNode ? (
isVideoStitchNode ? (
<div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300">
<span>
@ -2024,82 +2247,127 @@ export function GenerationComposer() {
</div>
)
) : (
<>
<button
type="button"
onClick={() => referenceImageInputRef.current?.click()}
disabled={!canEditReferenceImages || referenceImages.length >= MAX_REFERENCE_IMAGES}
title={canEditReferenceImages ? t("composer.addReferenceImage") : t("composer.referenceFromUpstream")}
className="flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg border border-neutral-700 bg-neutral-800 text-xs text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-base leading-none">+</span>
<span>{t("composer.reference")}</span>
</button>
{referenceImages.length > 0 && (
<div className="ml-1 flex max-w-[18rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1">
{referenceImages.map((referenceImage, imageIndex) => (
<div
key={`${imageIndex}-${referenceImage.slice(0, 24)}`}
className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900"
>
<button
type="button"
aria-label={t("composer.referenceImage", { index: imageIndex + 1 })}
title={t("composer.referenceImage", { index: imageIndex + 1 })}
onClick={() => setReferencePreviewIndex(imageIndex)}
className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => referenceImageInputRef.current?.click()}
disabled={!canEditReferenceImages || referenceImages.length >= MAX_REFERENCE_IMAGES}
title={canEditReferenceImages ? t("composer.addReferenceImage") : t("composer.referenceFromUpstream")}
className="flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg border border-neutral-700 bg-neutral-800 text-xs text-neutral-400 transition-colors hover:border-neutral-600 hover:text-neutral-200 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="text-base leading-none">+</span>
<span>{t("composer.reference")}</span>
</button>
{referenceMaterials.length > 0 && (
<div className="ml-1 flex max-w-[18rem] items-center gap-1.5 overflow-x-auto rounded-lg pr-1">
{referenceMaterials.map((material, materialIndex) => (
<div
key={`${material.type}-${material.id}-${material.url.slice(0, 24)}`}
className="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border border-neutral-700 bg-neutral-900"
>
<img
src={referenceImage}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20">
<svg
className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 3h6v6" />
<path d="M21 3l-7 7" />
<path d="M9 21H3v-6" />
<path d="M3 21l7-7" />
</svg>
</span>
</button>
<span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200">
{imageIndex + 1}
</span>
{canEditReferenceImages && (
<button
type="button"
aria-label={t("composer.removeReferenceImage", { index: imageIndex + 1 })}
onClick={(event) => {
event.stopPropagation();
removeReferenceImage(imageIndex);
aria-label={material.type === "image"
? t("composer.referenceImage", { index: material.id })
: material.name}
title={material.type === "image"
? t("composer.referenceImage", { index: material.id })
: material.name}
onClick={() => {
if (material.type === "image") setReferencePreviewIndex(materialIndex);
}}
className="absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-[10px] text-neutral-200 transition-colors hover:bg-red-600 hover:text-white"
className="group h-full w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/70"
>
?
{material.type === "image" ? (
<img
src={material.url}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
) : material.type === "video" ? (
<video
src={material.url}
muted
playsInline
preload="metadata"
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-violet-950/80 text-violet-100">
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
)}
<span className="absolute inset-0 flex items-center justify-center bg-black/0 text-white transition-colors group-hover:bg-black/20">
{material.type === "image" ? (
<svg
className="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-90"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 3h6v6" />
<path d="M21 3l-7 7" />
<path d="M9 21H3v-6" />
<path d="M3 21l7-7" />
</svg>
) : material.type === "video" ? (
<svg
className="h-5 w-5 opacity-90"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-5 w-5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
)}
</span>
</button>
)}
</div>
))}
</div>
)}
</>
<span className="pointer-events-none absolute left-1 top-1 rounded-full bg-neutral-900/80 px-1 text-[10px] text-neutral-200">
{isFirstLastFrameSelected && material.type === "image"
? material.id === 1
? t("composer.firstFrame")
: t("composer.lastFrame")
: material.id}
</span>
{canEditReferenceImages && material.type === "image" && material.removableIndex !== undefined && (
<button
type="button"
aria-label={t("composer.removeReferenceImage", { index: material.removableIndex + 1 })}
onClick={(event) => {
event.stopPropagation();
removeReferenceImage(material.removableIndex!);
}}
className="absolute bottom-1 right-1 flex h-4 w-4 items-center justify-center rounded-full bg-black/70 text-[10px] text-neutral-200 transition-colors hover:bg-red-600 hover:text-white"
>
?
</button>
)}
</div>
))}
</div>
)}
</div>
)}
<div className="ml-auto flex items-center gap-1 text-neutral-500">
<IconButton label={t("composer.collapse")} onClick={toggleComposerCollapsed}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 7 7 17" />
<path d="M16 17H7V8" />
</svg>
</IconButton>
<div className="ml-auto flex items-center gap-1 text-neutral-500">
<IconButton label={t("composer.collapse")} onClick={toggleComposerCollapsed}>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17 7 7 17" />
<path d="M16 17H7V8" />
</svg>
</IconButton>
</div>
</div>
</div>
@ -2129,17 +2397,38 @@ export function GenerationComposer() {
</div>
) : (
<div className="py-1">
{referenceImages.length > 0 ? (
referenceImages.map((referenceImage, imageIndex) => {
const referenceLabel = `${imageIndex + 1}`;
{referenceMaterials.length > 0 ? (
referenceMaterials.map((material) => {
const referenceLabel = material.name.replace(/^@/, "");
return (
<button
key={`${imageIndex}-${referenceImage.slice(0, 24)}`}
key={`${material.type}-${material.id}-${material.url.slice(0, 24)}`}
type="button"
onClick={() => insertReference(`@${referenceLabel} `)}
onClick={() => insertReference(`${material.name} `)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
>
<img src={referenceImage} alt="" className="h-8 w-8 rounded object-cover" />
<span className="relative h-8 w-8 shrink-0 overflow-hidden rounded bg-neutral-900">
{material.type === "image" ? (
<img src={material.url} alt="" className="h-8 w-8 object-cover" />
) : material.type === "video" ? (
<>
<video src={material.url} muted playsInline preload="metadata" className="h-8 w-8 object-cover" />
<span className="absolute inset-0 flex items-center justify-center bg-black/20 text-white">
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</>
) : (
<span className="flex h-8 w-8 items-center justify-center bg-violet-950/80 text-violet-100">
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</span>
)}
</span>
<span>{referenceLabel}</span>
</button>
);

17
src/i18n/index.tsx

@ -601,6 +601,13 @@ const en = {
"composer.reference": "Reference",
"composer.referenceImage": "Reference image {index}",
"composer.removeReferenceImage": "Remove reference image {index}",
"composer.videoSubTypeMultiReference": "Image to video",
"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.videoSubTypeFirstLastFrameImageCountRequired": "First/last frame requires exactly two reference images.",
"composer.firstFrame": "First",
"composer.lastFrame": "Last",
"composer.commands": "Commands",
"composer.referenceMaterials": "Reference assets",
"composer.commandCharacter": "Character image",
@ -1587,6 +1594,16 @@ const zhCN: Dictionary = {
"cost.viewDetails": "查看费用详情",
};
Object.assign(zhCN, {
"composer.videoSubTypeFirstLastFrameImageCountRequired": "\u9996\u5c3e\u5e27\u9700\u8981\u6070\u597d\u4e24\u5f20\u53c2\u8003\u56fe\u7247\u3002",
"composer.firstFrame": "\u9996\u5e27",
"composer.lastFrame": "\u5c3e\u5e27",
"composer.videoSubTypeMultiReference": "图生视频",
"composer.videoSubTypeTextToVideo": "全能参考",
"composer.videoSubTypeFirstLastFrame": "首尾帧",
"composer.videoSubTypeImageRequired": "图生视频或首尾帧至少需要一张参考图片。",
});
const zhTW: Dictionary = {
...zhCN,
...zhTWAuth,

Loading…
Cancel
Save