feat: add connected image-to-video composer
This commit is contained in:
parent
7f796a5bfd
commit
2cad27cdcd
@ -60,6 +60,7 @@ import {
|
||||
defaultVideoCanvasFunctionConfig,
|
||||
normalizeVideoCanvasFunctionConfig,
|
||||
} from "@/lib/video-canvas/config";
|
||||
import type { VideoGenerationMode, VideoRatio, VideoResolution } from "@/types/ai";
|
||||
|
||||
type ActivePanel =
|
||||
| "add"
|
||||
@ -97,6 +98,7 @@ type ComposerMenu =
|
||||
| "model"
|
||||
| "ratio"
|
||||
| "resolution"
|
||||
| "duration"
|
||||
| "camera"
|
||||
| "panorama"
|
||||
| "translate"
|
||||
@ -217,6 +219,22 @@ type ComposerSettings = {
|
||||
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 = {
|
||||
id: string;
|
||||
modelId?: string;
|
||||
@ -1393,6 +1411,87 @@ function getFunctionModelValue(
|
||||
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) {
|
||||
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 };
|
||||
|
||||
addNodes([node], [edge]);
|
||||
setSelectedNodeId(node.id);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setActivePanel(null);
|
||||
setConnectionMenu(null);
|
||||
toast.success(`已添加并连接 ${node.title}`);
|
||||
},
|
||||
@ -3997,6 +4100,205 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
[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(
|
||||
async (
|
||||
nodeId: string,
|
||||
@ -4292,43 +4594,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
}
|
||||
|
||||
if (node.inputType === "video" || /VIDEO_GENERATE/.test(action)) {
|
||||
const definition = getConfiguredFunction(
|
||||
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} 已生成视频结果`);
|
||||
await submitVideoTargetComposer(nodeId);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -4377,7 +4643,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : `${node.title} 生成失败`);
|
||||
}
|
||||
}, [edges, nodes, videoCanvasFunctionConfig]);
|
||||
}, [edges, nodes, submitVideoTargetComposer, videoCanvasFunctionConfig]);
|
||||
|
||||
const deleteNode = useCallback((nodeId: string) => {
|
||||
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 selectedTextResourceNode =
|
||||
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>(() => {
|
||||
if (!selectedPresetControlNode) return null;
|
||||
return {
|
||||
@ -4814,6 +5090,19 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
/>
|
||||
) : 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
|
||||
draftTitle={draftTitle}
|
||||
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({
|
||||
anchor,
|
||||
onToolAction,
|
||||
@ -7410,11 +7974,13 @@ function ComposerSlashPopover({ onSelect }: { onSelect: (scene: string) => void
|
||||
}
|
||||
|
||||
function ComposerPopover({
|
||||
customOptions,
|
||||
menu,
|
||||
modelKind = "image",
|
||||
modelOptions = [],
|
||||
onSelect,
|
||||
}: {
|
||||
customOptions?: Array<{ label: string; value: string }>;
|
||||
menu: Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>;
|
||||
modelKind?: CanvasModelKind;
|
||||
modelOptions?: CanvasModelOption[];
|
||||
@ -7423,6 +7989,7 @@ function ComposerPopover({
|
||||
const staticOptions: Record<Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>, Array<{ label: string; value: string }>> = {
|
||||
camera: ["摄像机", "手持镜头", "固定镜头"].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 })),
|
||||
model: [],
|
||||
panorama: ["全景", "半身", "近景"].map((value) => ({ label: value, value })),
|
||||
@ -7439,7 +8006,7 @@ function ComposerPopover({
|
||||
value: getCanvasModelRef(model),
|
||||
})),
|
||||
];
|
||||
const options = menu === "model" ? modelItems : staticOptions[menu];
|
||||
const options = customOptions || (menu === "model" ? modelItems : staticOptions[menu]);
|
||||
|
||||
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)]">
|
||||
@ -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" />
|
||||
{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} />
|
||||
) : (
|
||||
<div className="relative text-center">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user