feat: add connected image-to-video composer
This commit is contained in:
parent
7f796a5bfd
commit
2cad27cdcd
@ -60,6 +60,7 @@ import {
|
|||||||
defaultVideoCanvasFunctionConfig,
|
defaultVideoCanvasFunctionConfig,
|
||||||
normalizeVideoCanvasFunctionConfig,
|
normalizeVideoCanvasFunctionConfig,
|
||||||
} from "@/lib/video-canvas/config";
|
} from "@/lib/video-canvas/config";
|
||||||
|
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
|
||||||
|
|
||||||
type ActivePanel =
|
type ActivePanel =
|
||||||
| "add"
|
| "add"
|
||||||
@ -97,6 +98,7 @@ type ComposerMenu =
|
|||||||
| "model"
|
| "model"
|
||||||
| "ratio"
|
| "ratio"
|
||||||
| "resolution"
|
| "resolution"
|
||||||
|
| "duration"
|
||||||
| "camera"
|
| "camera"
|
||||||
| "panorama"
|
| "panorama"
|
||||||
| "translate"
|
| "translate"
|
||||||
@ -217,6 +219,22 @@ type ComposerSettings = {
|
|||||||
resolution: string;
|
resolution: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type VideoComposerTabKey =
|
||||||
|
| "text-to-video"
|
||||||
|
| "reference-images"
|
||||||
|
| "first-frame"
|
||||||
|
| "first-last-frame"
|
||||||
|
| "image-reference";
|
||||||
|
|
||||||
|
type VideoTargetComposerSubmitPayload = {
|
||||||
|
mode: VideoGenerationMode;
|
||||||
|
prompt: string;
|
||||||
|
settings: ComposerSettings & {
|
||||||
|
duration: string;
|
||||||
|
};
|
||||||
|
tab: VideoComposerTabKey;
|
||||||
|
};
|
||||||
|
|
||||||
type CanvasModelOption = {
|
type CanvasModelOption = {
|
||||||
id: string;
|
id: string;
|
||||||
modelId?: string;
|
modelId?: string;
|
||||||
@ -1393,6 +1411,87 @@ function getFunctionModelValue(
|
|||||||
return String(definition.model || fallback || "auto").trim() || "auto";
|
return String(definition.model || fallback || "auto").trim() || "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVideoGenerationMode(value: unknown): VideoGenerationMode | null {
|
||||||
|
const mode = String(value || "").trim();
|
||||||
|
if (
|
||||||
|
mode === "text-to-video" ||
|
||||||
|
mode === "first-frame" ||
|
||||||
|
mode === "first-last-frame" ||
|
||||||
|
mode === "reference-images"
|
||||||
|
) {
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVideoRatioValue(value: unknown): VideoRatio {
|
||||||
|
const ratio = String(value || "").trim();
|
||||||
|
if (
|
||||||
|
ratio === "16:9" ||
|
||||||
|
ratio === "4:3" ||
|
||||||
|
ratio === "1:1" ||
|
||||||
|
ratio === "3:4" ||
|
||||||
|
ratio === "9:16" ||
|
||||||
|
ratio === "21:9" ||
|
||||||
|
ratio === "adaptive"
|
||||||
|
) {
|
||||||
|
return ratio;
|
||||||
|
}
|
||||||
|
return "16:9";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVideoResolutionValue(value: unknown): VideoResolution {
|
||||||
|
const normalized = String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
if (normalized === "480p") return "480p";
|
||||||
|
if (normalized === "1080p" || normalized === "1k") return "1080p";
|
||||||
|
return "720p";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVideoResolutionLabel(value: unknown) {
|
||||||
|
const resolution = normalizeVideoResolutionValue(value);
|
||||||
|
return resolution === "480p" ? "480P" : resolution === "1080p" ? "1080P" : "720P";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVideoDurationValue(value: unknown) {
|
||||||
|
const matched = String(value || "").match(/(\d+)/);
|
||||||
|
const duration = matched ? Number.parseInt(matched[1], 10) : Number(value);
|
||||||
|
if (Number.isFinite(duration) && duration > 0) return duration;
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVideoDurationLabel(value: unknown) {
|
||||||
|
return `${normalizeVideoDurationValue(value)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferVideoComposerDefaultTab(imageCount: number): VideoComposerTabKey {
|
||||||
|
if (imageCount >= 2) return "reference-images";
|
||||||
|
if (imageCount >= 1) return "reference-images";
|
||||||
|
return "text-to-video";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVideoComposerMode(tab: VideoComposerTabKey): VideoGenerationMode {
|
||||||
|
if (tab === "first-last-frame") return "first-last-frame";
|
||||||
|
if (tab === "first-frame") return "first-frame";
|
||||||
|
if (tab === "reference-images" || tab === "image-reference") return "reference-images";
|
||||||
|
return "text-to-video";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoFunctionKeyForMode(mode: VideoGenerationMode) {
|
||||||
|
if (mode === "text-to-video") return "text-to-video" as const;
|
||||||
|
if (mode === "first-last-frame") return "first-last-frame-video" as const;
|
||||||
|
return "first-frame-video" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectVideoReferenceImages(images: string[], mode: VideoGenerationMode) {
|
||||||
|
if (mode === "text-to-video") return [];
|
||||||
|
if (mode === "first-frame") return images.slice(0, 1);
|
||||||
|
if (mode === "first-last-frame") return images.slice(0, 2);
|
||||||
|
return images.slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
function getCanvasModelRef(model: CanvasModelOption) {
|
function getCanvasModelRef(model: CanvasModelOption) {
|
||||||
return String(model.modelId || model.id || "").trim();
|
return String(model.modelId || model.id || "").trim();
|
||||||
}
|
}
|
||||||
@ -2618,6 +2717,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
: { id: makeId("edge"), source: node.id, target: anchor.id };
|
: { id: makeId("edge"), source: node.id, target: anchor.id };
|
||||||
|
|
||||||
addNodes([node], [edge]);
|
addNodes([node], [edge]);
|
||||||
|
setSelectedNodeId(node.id);
|
||||||
|
setCanvasCommand(null);
|
||||||
|
setNodeContextMenu(null);
|
||||||
|
setActivePanel(null);
|
||||||
setConnectionMenu(null);
|
setConnectionMenu(null);
|
||||||
toast.success(`已添加并连接 ${node.title}`);
|
toast.success(`已添加并连接 ${node.title}`);
|
||||||
},
|
},
|
||||||
@ -3997,6 +4100,205 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
[nodes, videoCanvasFunctionConfig]
|
[nodes, videoCanvasFunctionConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const submitVideoTargetComposer = useCallback(
|
||||||
|
async (
|
||||||
|
nodeId: string,
|
||||||
|
payload?: VideoTargetComposerSubmitPayload,
|
||||||
|
) => {
|
||||||
|
const node = nodes.find((item) => item.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const incomingNodes = edges
|
||||||
|
.filter((edge) => edge.target === nodeId)
|
||||||
|
.map((edge) => nodes.find((item) => item.id === edge.source))
|
||||||
|
.filter((item): item is CloneNode => Boolean(item));
|
||||||
|
const imageReferenceLists = await Promise.all(
|
||||||
|
incomingNodes
|
||||||
|
.filter((item) => item.inputType === "image")
|
||||||
|
.map((item) => resolveNodeReferenceImages(item))
|
||||||
|
);
|
||||||
|
const imageUrls = Array.from(
|
||||||
|
new Set(
|
||||||
|
imageReferenceLists
|
||||||
|
.flat()
|
||||||
|
.map((item) => String(item || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
).slice(0, 12);
|
||||||
|
const params = (node.draftValues?.params || {}) as Record<string, unknown>;
|
||||||
|
const paramsSettings =
|
||||||
|
params.settings && typeof params.settings === "object"
|
||||||
|
? (params.settings as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const requestedMode =
|
||||||
|
payload?.mode ||
|
||||||
|
normalizeVideoGenerationMode(params.mode || paramsSettings.mode) ||
|
||||||
|
(imageUrls.length >= 2 ? "first-last-frame" : imageUrls.length >= 1 ? "first-frame" : "text-to-video");
|
||||||
|
const definition = getConfiguredFunction(
|
||||||
|
videoCanvasFunctionConfig,
|
||||||
|
getVideoFunctionKeyForMode(requestedMode),
|
||||||
|
);
|
||||||
|
const requestedSettings = payload?.settings;
|
||||||
|
const preferredModelOverride = requestedSettings?.model || String(params.model || paramsSettings.model || "");
|
||||||
|
const preferredModelRef = getFunctionModel(videoCanvasFunctionConfig, definition, preferredModelOverride);
|
||||||
|
const ratio = normalizeVideoRatioValue(requestedSettings?.ratio || params.ratio || paramsSettings.ratio);
|
||||||
|
const resolution = normalizeVideoResolutionValue(
|
||||||
|
requestedSettings?.resolution || params.resolution || paramsSettings.resolution,
|
||||||
|
);
|
||||||
|
const duration = normalizeVideoDurationValue(
|
||||||
|
requestedSettings?.duration || params.duration || paramsSettings.duration,
|
||||||
|
);
|
||||||
|
const referenceImages = selectVideoReferenceImages(imageUrls, requestedMode);
|
||||||
|
if (requestedMode === "first-frame" && referenceImages.length < 1) {
|
||||||
|
toast.error("图生视频至少需要 1 张参考图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestedMode === "first-last-frame" && referenceImages.length < 2) {
|
||||||
|
toast.error("首尾帧模式至少需要 2 张参考图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestedMode === "reference-images" && referenceImages.length < 1) {
|
||||||
|
toast.error("全能参考至少需要 1 张参考图片");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPrompt = String(
|
||||||
|
payload?.prompt ?? params.prompt ?? node.draftValues?.prompt ?? "",
|
||||||
|
).trim();
|
||||||
|
const finalPrompt = composeFunctionPrompt(definition, rawPrompt || node.title);
|
||||||
|
const previewUrl =
|
||||||
|
referenceImages[0] ||
|
||||||
|
getNodeReferenceMediaUrl(incomingNodes.find((item) => item.inputType === "image")) ||
|
||||||
|
getNodeReferenceMediaUrl(node) ||
|
||||||
|
"";
|
||||||
|
const savedSettings: ComposerSettings & { duration: string } = {
|
||||||
|
camera: requestedSettings?.camera || String(paramsSettings.camera || "摄像机"),
|
||||||
|
count: requestedSettings?.count || String(paramsSettings.count || "1个"),
|
||||||
|
model: normalizeModelRef(preferredModelOverride) || "auto",
|
||||||
|
panorama: requestedSettings?.panorama || String(paramsSettings.panorama || "全能参考"),
|
||||||
|
ratio,
|
||||||
|
resolution: formatVideoResolutionLabel(resolution),
|
||||||
|
duration: formatVideoDurationLabel(duration),
|
||||||
|
};
|
||||||
|
|
||||||
|
setNodes((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === nodeId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
kind: "media",
|
||||||
|
subtitle: "生成中",
|
||||||
|
draftValues: {
|
||||||
|
...clearGridSplitDraftValues(item.draftValues),
|
||||||
|
action: "VIDEO_GENERATE",
|
||||||
|
generated: false,
|
||||||
|
generationError: undefined,
|
||||||
|
generationProgress: 2,
|
||||||
|
generationStatus: "pending",
|
||||||
|
mediaType: "video",
|
||||||
|
mediaUrl: "",
|
||||||
|
nodeType: "video",
|
||||||
|
poster: previewUrl,
|
||||||
|
params: {
|
||||||
|
...getNodeParams(item),
|
||||||
|
duration,
|
||||||
|
mode: requestedMode,
|
||||||
|
model: normalizeModelRef(preferredModelOverride) || undefined,
|
||||||
|
prompt: rawPrompt || finalPrompt,
|
||||||
|
ratio,
|
||||||
|
referenceImages,
|
||||||
|
resolution,
|
||||||
|
settings: savedSettings,
|
||||||
|
},
|
||||||
|
prompt: rawPrompt || finalPrompt,
|
||||||
|
sourceUrl: previewUrl,
|
||||||
|
url: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await videoGenerationService.generateVideo({
|
||||||
|
duration,
|
||||||
|
images: referenceImages,
|
||||||
|
mode: requestedMode,
|
||||||
|
preferredModelRef,
|
||||||
|
prompt: finalPrompt,
|
||||||
|
ratio,
|
||||||
|
resolution,
|
||||||
|
});
|
||||||
|
if (!response.success || !response.data?.url) {
|
||||||
|
throw new Error(response.error || "视频生成失败");
|
||||||
|
}
|
||||||
|
setNodes((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === nodeId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
kind: "media",
|
||||||
|
subtitle: "生成结果",
|
||||||
|
draftValues: {
|
||||||
|
...clearGridSplitDraftValues(item.draftValues),
|
||||||
|
action: "VIDEO_GENERATE",
|
||||||
|
generated: true,
|
||||||
|
generationProgress: 100,
|
||||||
|
generationStatus: "complete",
|
||||||
|
mediaType: "video",
|
||||||
|
mediaUrl: response.data?.url,
|
||||||
|
nodeType: "video",
|
||||||
|
outputSizeLabel: `${formatVideoResolutionLabel(resolution)} · ${duration}s`,
|
||||||
|
params: {
|
||||||
|
...getNodeParams(item),
|
||||||
|
duration,
|
||||||
|
mode: requestedMode,
|
||||||
|
model: normalizeModelRef(preferredModelOverride) || undefined,
|
||||||
|
prompt: rawPrompt || finalPrompt,
|
||||||
|
ratio,
|
||||||
|
referenceImages,
|
||||||
|
resolution,
|
||||||
|
settings: savedSettings,
|
||||||
|
},
|
||||||
|
poster: previewUrl,
|
||||||
|
prompt: rawPrompt || finalPrompt,
|
||||||
|
sourceUrl: previewUrl,
|
||||||
|
url: [response.data?.url],
|
||||||
|
videoMeta: response.data?.metadata || null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setSelectedNodeId(nodeId);
|
||||||
|
setCanvasCommand(null);
|
||||||
|
setNodeContextMenu(null);
|
||||||
|
setConnectionMenu(null);
|
||||||
|
setActivePanel(null);
|
||||||
|
toast.success("视频已开始生成并返回结果");
|
||||||
|
} catch (error) {
|
||||||
|
setNodes((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === nodeId
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
kind: "media",
|
||||||
|
subtitle: "生成失败",
|
||||||
|
draftValues: {
|
||||||
|
...clearGridSplitDraftValues(item.draftValues),
|
||||||
|
generationStatus: "failed",
|
||||||
|
generationError: error instanceof Error ? error.message : "视频生成失败",
|
||||||
|
poster: previewUrl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast.error(error instanceof Error ? error.message : "视频生成失败");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[edges, nodes, videoCanvasFunctionConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const runPresetComposerSubmit = useCallback(
|
const runPresetComposerSubmit = useCallback(
|
||||||
async (
|
async (
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
@ -4292,43 +4594,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (node.inputType === "video" || /VIDEO_GENERATE/.test(action)) {
|
if (node.inputType === "video" || /VIDEO_GENERATE/.test(action)) {
|
||||||
const definition = getConfiguredFunction(
|
await submitVideoTargetComposer(nodeId);
|
||||||
videoCanvasFunctionConfig,
|
|
||||||
imageUrls.length >= 2 ? "first-last-frame-video" : imageUrls.length === 1 ? "first-frame-video" : "text-to-video",
|
|
||||||
);
|
|
||||||
const prompt = composeFunctionPrompt(definition, rawPrompt || node.title);
|
|
||||||
const response = await videoGenerationService.generateVideo({
|
|
||||||
images: imageUrls.slice(0, 4),
|
|
||||||
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition, nodeModelRef),
|
|
||||||
prompt,
|
|
||||||
ratio: "16:9",
|
|
||||||
resolution: "720p",
|
|
||||||
});
|
|
||||||
if (!response.success || !response.data?.url) {
|
|
||||||
throw new Error(response.error || "视频生成失败");
|
|
||||||
}
|
|
||||||
setNodes((current) =>
|
|
||||||
current.map((item) =>
|
|
||||||
item.id === nodeId
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
kind: "media",
|
|
||||||
subtitle: "生成结果",
|
|
||||||
draftValues: {
|
|
||||||
...(item.draftValues || {}),
|
|
||||||
generated: true,
|
|
||||||
mediaType: "video",
|
|
||||||
mediaUrl: response.data?.url,
|
|
||||||
nodeType: "video",
|
|
||||||
prompt,
|
|
||||||
url: [response.data?.url],
|
|
||||||
videoMeta: response.data?.metadata || null,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
);
|
|
||||||
toast.success(`${node.title} 已生成视频结果`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -4377,7 +4643,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : `${node.title} 生成失败`);
|
toast.error(error instanceof Error ? error.message : `${node.title} 生成失败`);
|
||||||
}
|
}
|
||||||
}, [edges, nodes, videoCanvasFunctionConfig]);
|
}, [edges, nodes, submitVideoTargetComposer, videoCanvasFunctionConfig]);
|
||||||
|
|
||||||
const deleteNode = useCallback((nodeId: string) => {
|
const deleteNode = useCallback((nodeId: string) => {
|
||||||
setNodes((current) => current.filter((node) => node.id !== nodeId));
|
setNodes((current) => current.filter((node) => node.id !== nodeId));
|
||||||
@ -4622,6 +4888,16 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
const selectedTargetModuleKind = selectedNode ? getTargetModuleKind(selectedNode) : null;
|
const selectedTargetModuleKind = selectedNode ? getTargetModuleKind(selectedNode) : null;
|
||||||
const selectedTextResourceNode =
|
const selectedTextResourceNode =
|
||||||
selectedNode && getDraftString(selectedNode, "action") === "TEXT_RESOURCE" ? selectedNode : null;
|
selectedNode && getDraftString(selectedNode, "action") === "TEXT_RESOURCE" ? selectedNode : null;
|
||||||
|
const selectedTargetSourceNodes = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedNode && selectedTargetModuleKind
|
||||||
|
? edges
|
||||||
|
.filter((edge) => edge.target === selectedNode.id)
|
||||||
|
.map((edge) => nodes.find((item) => item.id === edge.source))
|
||||||
|
.filter((item): item is CloneNode => Boolean(item))
|
||||||
|
: [],
|
||||||
|
[edges, nodes, selectedNode, selectedTargetModuleKind]
|
||||||
|
);
|
||||||
const selectedPresetAnchor = useMemo<PresetControlAnchor | null>(() => {
|
const selectedPresetAnchor = useMemo<PresetControlAnchor | null>(() => {
|
||||||
if (!selectedPresetControlNode) return null;
|
if (!selectedPresetControlNode) return null;
|
||||||
return {
|
return {
|
||||||
@ -4814,6 +5090,19 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{selectedNode && selectedTargetModuleKind === "video" && selectedTargetAnchor ? (
|
||||||
|
<VideoTargetModuleComposer
|
||||||
|
anchor={selectedTargetAnchor}
|
||||||
|
functionConfig={videoCanvasFunctionConfig}
|
||||||
|
modelOptions={canvasModelOptions}
|
||||||
|
node={selectedNode}
|
||||||
|
sourceNodes={selectedTargetSourceNodes}
|
||||||
|
onSubmit={(payload) => {
|
||||||
|
void submitVideoTargetComposer(selectedNode.id, payload);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TopBar
|
<TopBar
|
||||||
draftTitle={draftTitle}
|
draftTitle={draftTitle}
|
||||||
isTitleEditing={isTitleEditing}
|
isTitleEditing={isTitleEditing}
|
||||||
@ -5839,6 +6128,281 @@ function TextTargetModuleComposer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VideoTargetModuleComposer({
|
||||||
|
anchor,
|
||||||
|
functionConfig,
|
||||||
|
modelOptions = [],
|
||||||
|
node,
|
||||||
|
onSubmit,
|
||||||
|
sourceNodes,
|
||||||
|
}: {
|
||||||
|
anchor: PresetControlAnchor;
|
||||||
|
functionConfig: VideoCanvasFunctionConfig;
|
||||||
|
modelOptions?: CanvasModelOption[];
|
||||||
|
node: CloneNode;
|
||||||
|
onSubmit: (payload: VideoTargetComposerSubmitPayload) => void;
|
||||||
|
sourceNodes: CloneNode[];
|
||||||
|
}) {
|
||||||
|
const params = (node.draftValues?.params || {}) as Record<string, unknown>;
|
||||||
|
const paramsSettings =
|
||||||
|
params.settings && typeof params.settings === "object"
|
||||||
|
? (params.settings as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const imageSources = sourceNodes.filter((item) => item.inputType === "image");
|
||||||
|
const referenceCount = imageSources.length;
|
||||||
|
const savedMode = normalizeVideoGenerationMode(params.mode || paramsSettings.mode);
|
||||||
|
const initialTab = (() => {
|
||||||
|
if (savedMode === "first-last-frame") return "first-last-frame" as const;
|
||||||
|
if (savedMode === "first-frame") return "first-frame" as const;
|
||||||
|
if (savedMode === "reference-images") return "reference-images" as const;
|
||||||
|
if (savedMode === "text-to-video") return "text-to-video" as const;
|
||||||
|
return inferVideoComposerDefaultTab(referenceCount);
|
||||||
|
})();
|
||||||
|
const initialPrompt = String(params.prompt || node.draftValues?.prompt || "").trim();
|
||||||
|
const initialSettings: ComposerSettings & { duration: string } = {
|
||||||
|
camera: String(paramsSettings.camera || "摄像机"),
|
||||||
|
count: String(paramsSettings.count || "1个"),
|
||||||
|
model:
|
||||||
|
normalizeModelRef(String(params.model || paramsSettings.model || "")) ||
|
||||||
|
getFunctionModelValue(
|
||||||
|
functionConfig,
|
||||||
|
getConfiguredFunction(functionConfig, getVideoFunctionKeyForMode(resolveVideoComposerMode(initialTab))),
|
||||||
|
),
|
||||||
|
panorama: String(paramsSettings.panorama || "全能参考"),
|
||||||
|
ratio: normalizeVideoRatioValue(params.ratio || paramsSettings.ratio),
|
||||||
|
resolution: formatVideoResolutionLabel(params.resolution || paramsSettings.resolution),
|
||||||
|
duration: formatVideoDurationLabel(params.duration || paramsSettings.duration),
|
||||||
|
};
|
||||||
|
const [activeTab, setActiveTab] = useState<VideoComposerTabKey>(initialTab);
|
||||||
|
const [menu, setMenu] = useState<ComposerMenu>(null);
|
||||||
|
const [prompt, setPrompt] = useState(initialPrompt);
|
||||||
|
const [settings, setSettings] = useState<ComposerSettings & { duration: string }>(initialSettings);
|
||||||
|
const activeMode = resolveVideoComposerMode(activeTab);
|
||||||
|
const activeDefinition = getConfiguredFunction(functionConfig, getVideoFunctionKeyForMode(activeMode));
|
||||||
|
const activeModelFallback = normalizeModelRef(getFunctionModelValue(functionConfig, activeDefinition)) || "auto";
|
||||||
|
const activeMenu =
|
||||||
|
menu === "model" || menu === "ratio" || menu === "resolution" || menu === "duration" ? menu : null;
|
||||||
|
const activePromptPlaceholder =
|
||||||
|
activeMode === "text-to-video"
|
||||||
|
? "描述视频画面、动作、情绪与镜头语言。"
|
||||||
|
: activeMode === "first-last-frame"
|
||||||
|
? "描述首帧到尾帧之间的动作变化、转场与镜头节奏。"
|
||||||
|
: "补充参考图中的动作、镜头、节奏与氛围,让视频更准确。";
|
||||||
|
const tabItems: Array<{ key: VideoComposerTabKey; label: string; enabled: boolean }> = [
|
||||||
|
{ key: "text-to-video", label: "文生视频", enabled: true },
|
||||||
|
{ key: "reference-images", label: "全能参考", enabled: referenceCount >= 1 },
|
||||||
|
{ key: "first-frame", label: "图生视频", enabled: referenceCount >= 1 },
|
||||||
|
{ key: "first-last-frame", label: "首尾帧", enabled: referenceCount >= 2 },
|
||||||
|
{ key: "image-reference", label: "图片参考", enabled: referenceCount >= 1 },
|
||||||
|
];
|
||||||
|
const referenceThumbnails = imageSources
|
||||||
|
.map((item) => ({ id: item.id, title: item.title, url: getNodeReferenceMediaUrl(item) }))
|
||||||
|
.filter((item) => item.url)
|
||||||
|
.slice(0, 4);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTab(initialTab);
|
||||||
|
setPrompt(initialPrompt);
|
||||||
|
setSettings(initialSettings);
|
||||||
|
}, [initialPrompt, initialTab, node.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tabItems.find((item) => item.key === activeTab)?.enabled) {
|
||||||
|
setActiveTab(inferVideoComposerDefaultTab(referenceCount));
|
||||||
|
}
|
||||||
|
}, [activeTab, referenceCount, tabItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettings((current) => {
|
||||||
|
const normalized = normalizeModelRef(current.model) || "auto";
|
||||||
|
if (normalized !== "auto") return current;
|
||||||
|
if (normalized === activeModelFallback) return current;
|
||||||
|
return { ...current, model: activeModelFallback };
|
||||||
|
});
|
||||||
|
}, [activeModelFallback]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute z-30 overflow-visible"
|
||||||
|
data-canvas-overlay="true"
|
||||||
|
style={{
|
||||||
|
height: 260,
|
||||||
|
left: anchor.centerX,
|
||||||
|
top: anchor.bottomY + 18,
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: 1216,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative h-[260px] w-[1216px] rounded-[16px] border border-white/8 bg-[#252525]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||||||
|
{activeMenu ? (
|
||||||
|
<ComposerPopover
|
||||||
|
customOptions={
|
||||||
|
activeMenu === "resolution"
|
||||||
|
? [
|
||||||
|
{ label: "480P", value: "480P" },
|
||||||
|
{ label: "720P", value: "720P" },
|
||||||
|
{ label: "1080P", value: "1080P" },
|
||||||
|
]
|
||||||
|
: activeMenu === "duration"
|
||||||
|
? [
|
||||||
|
{ label: "5s", value: "5s" },
|
||||||
|
{ label: "10s", value: "10s" },
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
menu={activeMenu}
|
||||||
|
modelKind={activeMenu === "model" ? "video" : "image"}
|
||||||
|
modelOptions={modelOptions}
|
||||||
|
onSelect={(option) => {
|
||||||
|
if (activeMenu === "model") {
|
||||||
|
setSettings((current) => ({ ...current, model: option }));
|
||||||
|
} else if (activeMenu === "ratio") {
|
||||||
|
setSettings((current) => ({ ...current, ratio: option }));
|
||||||
|
} else if (activeMenu === "resolution") {
|
||||||
|
setSettings((current) => ({ ...current, resolution: option }));
|
||||||
|
} else if (activeMenu === "duration") {
|
||||||
|
setSettings((current) => ({ ...current, duration: option }));
|
||||||
|
}
|
||||||
|
setMenu(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="absolute right-6 top-5 text-[32px] text-white/38 hover:text-white/70"
|
||||||
|
onClick={() => toast.info("视频编辑器扩展视图待接入。")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
↗
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{tabItems.map((item) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"h-[38px] rounded-[8px] border border-white/10 px-4 text-[18px] transition",
|
||||||
|
item.enabled
|
||||||
|
? item.key === activeTab
|
||||||
|
? "bg-white/10 text-white"
|
||||||
|
: "text-white/56 hover:bg-white/6 hover:text-white/86"
|
||||||
|
: "cursor-not-allowed text-white/22"
|
||||||
|
)}
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => {
|
||||||
|
if (!item.enabled) {
|
||||||
|
toast.info(item.key === "first-last-frame" ? "首尾帧模式至少需要 2 张参考图片。" : "请先接入参考图片。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveTab(item.key);
|
||||||
|
setSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
panorama: item.label,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
{[
|
||||||
|
["标记", "◎"],
|
||||||
|
["运镜", "▣"],
|
||||||
|
["角色库", "♙"],
|
||||||
|
].map(([label, icon]) => (
|
||||||
|
<button
|
||||||
|
className="flex h-[72px] w-[92px] flex-col items-center justify-center gap-1 rounded-[8px] border border-white/10 text-[16px] text-white/50 transition hover:bg-white/[0.07] hover:text-white/80"
|
||||||
|
key={label}
|
||||||
|
onClick={() => toast.info(`${label}参数后端待接入。`)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="text-[26px] leading-none">{icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{referenceThumbnails.length > 0 ? (
|
||||||
|
referenceThumbnails.map((thumbnail, index) => (
|
||||||
|
<div className="relative h-[72px] w-[86px] overflow-hidden rounded-[9px]" key={thumbnail.id}>
|
||||||
|
<img alt={thumbnail.title} className="h-full w-full object-cover" src={thumbnail.url} />
|
||||||
|
<span className="absolute right-1 top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-black/60 px-1 text-[12px]">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[72px] min-w-[180px] items-center justify-center rounded-[9px] border border-dashed border-white/10 px-4 text-[14px] text-white/34">
|
||||||
|
接入图片参考后可直接图生视频
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="mt-5 h-[62px] w-full resize-none bg-transparent text-[24px] leading-8 text-white/78 outline-none placeholder:text-white/46"
|
||||||
|
onChange={(event) => setPrompt(event.target.value)}
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
placeholder={activePromptPlaceholder}
|
||||||
|
value={prompt}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-5 left-5 right-[86px] flex items-center gap-7 overflow-hidden text-[22px] text-white/82">
|
||||||
|
<ComposerControl
|
||||||
|
active={menu === "model"}
|
||||||
|
dropdown
|
||||||
|
icon="▥"
|
||||||
|
label={getCanvasModelDisplayName(settings.model, modelOptions)}
|
||||||
|
onClick={() => setMenu((current) => current === "model" ? null : "model")}
|
||||||
|
/>
|
||||||
|
<ComposerControl
|
||||||
|
active={menu === "ratio"}
|
||||||
|
dropdown
|
||||||
|
icon="▭"
|
||||||
|
label={settings.ratio}
|
||||||
|
onClick={() => setMenu((current) => current === "ratio" ? null : "ratio")}
|
||||||
|
/>
|
||||||
|
<ComposerControl
|
||||||
|
active={menu === "resolution"}
|
||||||
|
dropdown
|
||||||
|
label={settings.resolution}
|
||||||
|
onClick={() => setMenu((current) => current === "resolution" ? null : "resolution")}
|
||||||
|
/>
|
||||||
|
<ComposerControl
|
||||||
|
active={menu === "duration"}
|
||||||
|
dropdown
|
||||||
|
label={settings.duration}
|
||||||
|
onClick={() => setMenu((current) => current === "duration" ? null : "duration")}
|
||||||
|
/>
|
||||||
|
<ComposerControl label="" icon="♬" muted onClick={() => toast.info("音频控制后端待接入。")} />
|
||||||
|
<ComposerControl label="文" onClick={() => toast.info("翻译接口待接入。")} />
|
||||||
|
<ComposerControl label="" icon="⌘" onClick={() => toast.info("高级视频参数后端待接入。")} />
|
||||||
|
<ComposerControl label="1个" dropdown onClick={() => toast.info("当前仅支持单次生成 1 个视频结果。")} />
|
||||||
|
<ComposerControl
|
||||||
|
label={String(activeDefinition.creditCost || 14)}
|
||||||
|
icon="⚡"
|
||||||
|
muted
|
||||||
|
onClick={() => toast.info("积分估算接入本地积分系统。")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="absolute bottom-5 right-5 flex h-14 w-14 items-center justify-center rounded-[14px] bg-white text-[28px] leading-none text-black hover:bg-white/90"
|
||||||
|
onClick={() =>
|
||||||
|
onSubmit({
|
||||||
|
mode: activeMode,
|
||||||
|
prompt,
|
||||||
|
settings: {
|
||||||
|
...settings,
|
||||||
|
camera: settings.camera,
|
||||||
|
count: "1个",
|
||||||
|
panorama: tabItems.find((item) => item.key === activeTab)?.label || settings.panorama,
|
||||||
|
},
|
||||||
|
tab: activeTab,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
⟳
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CharacterPresetToolbar({
|
function CharacterPresetToolbar({
|
||||||
anchor,
|
anchor,
|
||||||
onToolAction,
|
onToolAction,
|
||||||
@ -7410,11 +7974,13 @@ function ComposerSlashPopover({ onSelect }: { onSelect: (scene: string) => void
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ComposerPopover({
|
function ComposerPopover({
|
||||||
|
customOptions,
|
||||||
menu,
|
menu,
|
||||||
modelKind = "image",
|
modelKind = "image",
|
||||||
modelOptions = [],
|
modelOptions = [],
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
|
customOptions?: Array<{ label: string; value: string }>;
|
||||||
menu: Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>;
|
menu: Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>;
|
||||||
modelKind?: CanvasModelKind;
|
modelKind?: CanvasModelKind;
|
||||||
modelOptions?: CanvasModelOption[];
|
modelOptions?: CanvasModelOption[];
|
||||||
@ -7423,6 +7989,7 @@ function ComposerPopover({
|
|||||||
const staticOptions: Record<Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>, Array<{ label: string; value: string }>> = {
|
const staticOptions: Record<Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>, Array<{ label: string; value: string }>> = {
|
||||||
camera: ["摄像机", "手持镜头", "固定镜头"].map((value) => ({ label: value, value })),
|
camera: ["摄像机", "手持镜头", "固定镜头"].map((value) => ({ label: value, value })),
|
||||||
count: ["1张", "2张", "4张"].map((value) => ({ label: value, value })),
|
count: ["1张", "2张", "4张"].map((value) => ({ label: value, value })),
|
||||||
|
duration: ["5s", "10s"].map((value) => ({ label: value, value })),
|
||||||
mark: ["添加人物标记", "添加构图标记", "添加动作标记"].map((value) => ({ label: value, value })),
|
mark: ["添加人物标记", "添加构图标记", "添加动作标记"].map((value) => ({ label: value, value })),
|
||||||
model: [],
|
model: [],
|
||||||
panorama: ["全景", "半身", "近景"].map((value) => ({ label: value, value })),
|
panorama: ["全景", "半身", "近景"].map((value) => ({ label: value, value })),
|
||||||
@ -7439,7 +8006,7 @@ function ComposerPopover({
|
|||||||
value: getCanvasModelRef(model),
|
value: getCanvasModelRef(model),
|
||||||
})),
|
})),
|
||||||
];
|
];
|
||||||
const options = menu === "model" ? modelItems : staticOptions[menu];
|
const options = customOptions || (menu === "model" ? modelItems : staticOptions[menu]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-5 top-[102px] z-50 max-h-[280px] w-[300px] overflow-y-auto rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
|
<div className="absolute left-5 top-[102px] z-50 max-h-[280px] w-[300px] overflow-y-auto rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
|
||||||
@ -8565,7 +9132,15 @@ function PresetMediaCanvasNode({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.12)_1px,transparent_1px)] bg-[length:18px_18px] opacity-20" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.12)_1px,transparent_1px)] bg-[length:18px_18px] opacity-20" />
|
||||||
{mediaUrl ? (
|
{isGenerating || isGenerationFailed ? (
|
||||||
|
<GenerationPendingPreview
|
||||||
|
errorMessage={getDraftString(node, "generationError")}
|
||||||
|
onCancel={onCancelGeneration}
|
||||||
|
previewUrl={getDraftString(node, "poster") || sourceUrl || mediaUrl}
|
||||||
|
progress={getDraftNumber(node, "generationProgress", 2)}
|
||||||
|
status={generationStatus}
|
||||||
|
/>
|
||||||
|
) : mediaUrl ? (
|
||||||
<video className="relative h-full w-full object-cover" controls playsInline src={mediaUrl} />
|
<video className="relative h-full w-full object-cover" controls playsInline src={mediaUrl} />
|
||||||
) : (
|
) : (
|
||||||
<div className="relative text-center">
|
<div className="relative text-center">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user