feat: add connected image-to-video composer
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-26 19:55:36 +08:00
parent 7f796a5bfd
commit 2cad27cdcd

View File

@ -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">