Restore LibTV module action workflows
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-18 18:31:31 +08:00
parent 5ba8900438
commit 3278776e7f

View File

@ -190,6 +190,19 @@ type ComposerSettings = {
type AddModuleKind = "text" | "image" | "video" | "video-clip" | "audio" | "script";
type CanvasCreateKind = AddModuleKind;
type TargetModuleActionLabel =
| "自己编写内容"
| "文生视频"
| "图片反推提示词"
| "文字生音乐"
| "图生图"
| "图片高清"
| "音频生视频"
| "首尾帧生成视频"
| "首帧生成视频"
| "脚本生成分镜脚本"
| "视频参考生成分镜脚本"
| "角色生成分镜脚本";
type LiblibVideoCanvasReplicaProps = {
canvasId: string;
@ -394,6 +407,31 @@ const CREDIT_PACKS = [
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
const AUDIO_TO_VIDEO_PROMPT =
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
const TEXT_TO_VIDEO_PROMPT =
"爆款情绪短片\n傍晚的城市天台,一个年轻人迎着晚风张开双手,镜头从远景缓慢推进到中景,头发被风吹起,夕阳逆光,光晕明显,空气中有漂浮微尘,电影级色彩分级,浅景深,情绪张力强,慢动作";
const TEXT_TO_VIDEO_GENERATE_PROMPT = "根据文字描述生成视频。";
const TEXT_TO_MUSIC_PROMPT =
"生成一首现代品牌电子音乐,干净有力的低频贝斯,清晰电子鼓点,整体风格高级、未来感强。";
const IMAGE_TO_PROMPT_PROMPT = "根据图片生成结构化中文提示词,包括主体描述、环境、光影、镜头语言、风格关键词。";
const HD_IMAGE_SOURCE_URL =
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/5d40e509-6182-4d5d-b202-8e130b44a66c.png";
const VIDEO_FIRST_FRAME_SOURCE_URL =
"https://liblibai-online.liblib.cloud/web/28aa80df5944499487381983a8f9bbdd/604bb8d4-e8d1-439d-80b4-9867a579d0e7.png";
const VIDEO_FIRST_FRAME_PROMPT = "以当前图为首帧生成视频。";
const VIDEO_FIRST_LAST_FIRST_URL =
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/3dbb2a8e-47a7-4480-9de0-293adea8fbb4.png";
const VIDEO_FIRST_LAST_LAST_URL =
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/cea9eb03-e93f-45ab-b1c3-42d749d95321.png";
const VIDEO_FIRST_LAST_PROMPT =
"手从白帆布包中取出青柠气泡水罐的特写,过渡到短发女生在户外草地闭眼仰头喝同款气泡水的中景,日系清新治愈风格,明亮自然光,柔和暖调,胶片质感,动作流畅自然";
const SCRIPT_VIDEO_REFERENCE_URL =
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/7a21a9b9-503b-4168-b9c2-be0a3770f215.mp4";
const SCRIPT_CHARACTER_ONE_URL =
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/b6989b27-e725-4958-a062-e8dfb3312db3.png";
const SCRIPT_CHARACTER_TWO_URL =
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/fe840bb1-649e-4664-a23d-30f5ecf8e36c.png";
const SCRIPT_CHARACTER_PROMPT =
"根据角色参考图生成分镜脚本,围绕旧城区、机械修复、暴雨停电和被重新点亮的街道展开,突出人物关系、镜头画面和声音设计。";
const SLASH_IMAGE_ITEMS = [
"剧情推演四宫格",
"25宫格连贯分镜",
@ -828,6 +866,89 @@ function createUploadedMediaNode(file: File, mediaUrl: string, x: number, y: num
};
}
function createMediaResourceNode({
action,
height,
mediaType,
mediaUrl,
title,
width,
x,
y,
prompt,
}: {
action?: string;
height?: number;
mediaType: NonNullable<CloneNode["inputType"]>;
mediaUrl: string;
prompt?: string;
title: string;
width?: number;
x: number;
y: number;
}): CloneNode {
const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : mediaType === "text" ? "文本" : "图片";
return {
id: makeId(mediaType),
kind: "media",
title,
subtitle: `${label}资源`,
inputType: mediaType,
x,
y,
width: width ?? (mediaType === "audio" ? 430 : mediaType === "video" ? 560 : 420),
height: height ?? (mediaType === "audio" ? 170 : mediaType === "video" ? 315 : 280),
accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
draftValues: {
action: action ?? `${mediaType.toUpperCase()}_RESOURCE`,
alt: title,
generated: false,
mediaType,
mediaUrl,
nodeType: mediaType,
params: prompt ? { prompt } : undefined,
prompt: prompt || "",
url: [mediaUrl],
},
};
}
function createTextResourceNode({
content,
height = 430,
title,
width = 560,
x,
y,
}: {
content: string;
height?: number;
title: string;
width?: number;
x: number;
y: number;
}): CloneNode {
return {
id: makeId("text"),
kind: "script",
title,
subtitle: "文本资源",
inputType: "text",
x,
y,
width,
height,
accent: "#f0d06b",
draftValues: {
action: "TEXT_RESOURCE",
content: [content],
nodeType: "text",
scriptRole: "resource",
viewMode: undefined,
},
};
}
function getDraftString(node: CloneNode | null | undefined, key: string) {
const value = node?.draftValues?.[key];
return typeof value === "string" ? value : "";
@ -848,6 +969,9 @@ function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
function getTargetModuleKind(node: CloneNode): AddModuleKind | null {
if (node.kind === "media" || getPresetMode(node)) return null;
if (getDraftString(node, "action") === "TEXT_RESOURCE" || getDraftString(node, "scriptRole") === "resource") {
return null;
}
const nodeType = getDraftString(node, "nodeType");
if (
nodeType === "text" ||
@ -1347,6 +1471,275 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
[addNodes, connectionMenu, nodes]
);
const runTargetModuleAction = useCallback(
(nodeId: string, actionLabel: TargetModuleActionLabel) => {
const anchor = nodes.find((node) => node.id === nodeId);
if (!anchor) return;
const leftX = Math.round(anchor.x - 560 - 120);
const rightX = Math.round(anchor.x + anchor.width + 120);
const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2);
const nextNodes: CloneNode[] = [];
const nextEdges: CloneEdge[] = [];
let selectedId = anchor.id;
let anchorUpdates: Partial<CloneNode> = {};
let draftUpdates: Record<string, unknown> = {};
const connectToAnchor = (node: CloneNode) => {
nextNodes.push(node);
nextEdges.push({ id: makeId("edge"), source: node.id, target: anchor.id });
selectedId = node.id;
};
const connectFromAnchor = (node: CloneNode) => {
nextNodes.push(node);
nextEdges.push({ id: makeId("edge"), source: anchor.id, target: node.id });
selectedId = node.id;
};
const makeGenerated = (kind: Exclude<AddModuleKind, "audio"> | "audio", title: string, x: number, updates?: Record<string, unknown>) => {
const created = createTargetModuleNode(kind, x, 0, nodes.length + nextNodes.length + 1);
return {
...created,
title,
y: centerY(created.height),
draftValues: {
...(created.draftValues || {}),
...(updates || {}),
},
};
};
switch (actionLabel) {
case "自己编写内容": {
draftUpdates = { action: "TEXT_RESOURCE", content: [], manualEditing: true };
toast.success("已切换为文本编写节点");
break;
}
case "文生视频": {
draftUpdates = { action: "TEXT_GENERATE", content: [TEXT_TO_VIDEO_PROMPT] };
connectFromAnchor(makeGenerated("video", "视频", rightX, { params: { prompt: TEXT_TO_VIDEO_GENERATE_PROMPT } }));
break;
}
case "图片反推提示词": {
const image = createMediaResourceNode({
mediaType: "image",
mediaUrl: FIRST_FRAME_IMAGE_URL,
title: "图片",
width: 420,
height: 280,
x: leftX,
y: centerY(280),
});
draftUpdates = {
action: "TEXT_GENERATE",
content: [],
params: { ...(anchor.draftValues?.params as Record<string, unknown> | undefined), prompt: IMAGE_TO_PROMPT_PROMPT },
};
connectToAnchor(image);
break;
}
case "文字生音乐": {
draftUpdates = { action: "TEXT_GENERATE", content: [TEXT_TO_MUSIC_PROMPT] };
connectFromAnchor(makeGenerated("audio", "音频", rightX, { params: { scene: "Music" } }));
break;
}
case "图生图": {
anchorUpdates = {
kind: "media",
title: "图片",
subtitle: "图片资源",
width: 420,
height: 280,
inputType: "image",
};
draftUpdates = {
action: "IMAGE_RESOURCE",
generated: false,
mediaType: "image",
mediaUrl: CHARACTER_IMAGE_URL,
nodeType: "image",
url: [CHARACTER_IMAGE_URL],
};
connectFromAnchor(makeGenerated("image", "图片生成", rightX));
break;
}
case "图片高清": {
anchorUpdates = {
kind: "media",
title: "图片",
subtitle: "图片资源",
width: 420,
height: 280,
inputType: "image",
};
draftUpdates = {
action: "IMAGE_RESOURCE",
generated: false,
mediaType: "image",
mediaUrl: HD_IMAGE_SOURCE_URL,
nodeType: "image",
url: [HD_IMAGE_SOURCE_URL],
};
connectFromAnchor(makeGenerated("image", "高清", rightX, { generatorType: "ENHANCE" }));
break;
}
case "音频生视频": {
anchorUpdates = {
kind: "media",
title: "音频",
subtitle: "音频资源",
width: 430,
height: 170,
inputType: "audio",
};
draftUpdates = {
action: "AUDIO_RESOURCE",
generated: false,
mediaType: "audio",
mediaUrl: AUDIO_SOURCE_URL,
nodeType: "audio",
url: [AUDIO_SOURCE_URL],
};
const video = makeGenerated("video", "视频", rightX, { params: { prompt: AUDIO_TO_VIDEO_PROMPT } });
const image = createMediaResourceNode({
mediaType: "image",
mediaUrl: AUDIO_REFERENCE_IMAGE_URL,
title: "图片",
width: 430,
height: 252,
x: anchor.x,
y: Math.round(anchor.y + 170 + 120),
});
nextNodes.push(video, image);
nextEdges.push(
{ id: makeId("edge"), source: anchor.id, target: video.id },
{ id: makeId("edge"), source: image.id, target: video.id }
);
selectedId = video.id;
break;
}
case "首帧生成视频": {
draftUpdates = { action: "VIDEO_GENERATE", params: { prompt: VIDEO_FIRST_FRAME_PROMPT } };
connectToAnchor(
createMediaResourceNode({
mediaType: "image",
mediaUrl: VIDEO_FIRST_FRAME_SOURCE_URL,
title: "首帧",
width: 420,
height: 280,
x: leftX,
y: centerY(280),
})
);
selectedId = anchor.id;
break;
}
case "首尾帧生成视频": {
draftUpdates = { action: "VIDEO_GENERATE", params: { prompt: VIDEO_FIRST_LAST_PROMPT } };
const first = createMediaResourceNode({
mediaType: "image",
mediaUrl: VIDEO_FIRST_LAST_FIRST_URL,
title: "首帧",
width: 420,
height: 240,
x: leftX,
y: Math.round(anchor.y + 18),
});
const last = createMediaResourceNode({
mediaType: "image",
mediaUrl: VIDEO_FIRST_LAST_LAST_URL,
title: "尾帧",
width: 420,
height: 240,
x: leftX,
y: Math.round(anchor.y + 286),
});
connectToAnchor(first);
connectToAnchor(last);
selectedId = anchor.id;
break;
}
case "脚本生成分镜脚本": {
connectToAnchor(createTextResourceNode({ content: STORY_SCRIPT_TEXT, title: "文本", x: leftX, y: centerY(430) }));
selectedId = anchor.id;
break;
}
case "视频参考生成分镜脚本": {
draftUpdates = { action: "SCRIPT_GENERATE", params: { prompt: "解析视频内容生成分镜脚本。" } };
connectToAnchor(
createMediaResourceNode({
action: "VIDEO_RESOURCE",
mediaType: "video",
mediaUrl: SCRIPT_VIDEO_REFERENCE_URL,
prompt: "解析视频内容生成分镜脚本。",
title: "视频",
width: 560,
height: 315,
x: leftX,
y: centerY(315),
})
);
selectedId = anchor.id;
break;
}
case "角色生成分镜脚本": {
draftUpdates = { action: "SCRIPT_GENERATE", params: { prompt: SCRIPT_CHARACTER_PROMPT } };
const first = createMediaResourceNode({
mediaType: "image",
mediaUrl: SCRIPT_CHARACTER_ONE_URL,
title: "角色 1",
width: 300,
height: 300,
x: leftX,
y: Math.round(anchor.y + 10),
});
const second = createMediaResourceNode({
mediaType: "image",
mediaUrl: SCRIPT_CHARACTER_TWO_URL,
title: "角色 2",
width: 300,
height: 300,
x: leftX,
y: Math.round(anchor.y + 330),
});
connectToAnchor(first);
connectToAnchor(second);
selectedId = anchor.id;
break;
}
default:
break;
}
setNodes((current) => [
...current.map((item) =>
item.id === anchor.id
? {
...item,
...anchorUpdates,
draftValues: {
...(item.draftValues || {}),
...draftUpdates,
},
}
: item
),
...nextNodes,
]);
if (nextEdges.length > 0) {
setEdges((current) => [...current, ...nextEdges]);
}
setSelectedNodeId(selectedId);
setNodeContextMenu(null);
setConnectionMenu(null);
setCanvasCommand(null);
setActivePanel(null);
if (actionLabel !== "自己编写内容") toast.success(`已按「${actionLabel}」创建关联节点`);
},
[nodes]
);
const createFreeNode = useCallback(
(kind: CanvasCreateKind) => {
if (!canvasCommand) return;
@ -1638,6 +2031,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
node={node}
selected={node.id === selectedNodeId}
onContextMenu={(event) => openNodeContext(event, node)}
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
onPointerDown={(event) => startNodeDrag(event, node)}
/>
@ -3565,12 +3959,14 @@ function CloneEdges({ edges, nodes }: { edges: CloneEdge[]; nodes: CloneNode[] }
function CloneCanvasNode({
node,
onContextMenu,
onModuleAction,
onOpenConnectionMenu,
onPointerDown,
selected,
}: {
node: CloneNode;
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
onModuleAction: (actionLabel: TargetModuleActionLabel) => void;
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
selected: boolean;
@ -3579,7 +3975,7 @@ function CloneCanvasNode({
if (targetModuleKind) {
return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
<TargetModuleCanvasNode kind={targetModuleKind} node={node} selected={selected} />
<TargetModuleCanvasNode kind={targetModuleKind} node={node} onAction={onModuleAction} selected={selected} />
</CanvasNodeShell>
);
}
@ -3662,14 +4058,16 @@ function CloneCanvasNode({
function TargetModuleCanvasNode({
kind,
node,
onAction,
selected,
}: {
kind: AddModuleKind;
node: CloneNode;
onAction: (actionLabel: TargetModuleActionLabel) => void;
selected: boolean;
}) {
const HeaderIcon = kind === "video-clip" ? Scissors : kind === "audio" ? Music2 : kind === "video" ? Video : kind === "image" ? ImageIcon : FileText;
const actions: Record<AddModuleKind, Array<{ icon: LucideIcon | "hd"; label: string }>> = {
const actions: Record<AddModuleKind, Array<{ icon: LucideIcon | "hd"; label: TargetModuleActionLabel }>> = {
audio: [{ icon: Play, label: "音频生视频" }],
image: [
{ icon: Upload, label: "图生图" },
@ -3728,7 +4126,7 @@ function TargetModuleCanvasNode({
<button
className="flex items-center gap-3 text-[18px] leading-none text-white/82 hover:text-white"
key={action.label}
onClick={() => toast.info(`${action.label} 接口待接入。`)}
onClick={() => onAction(action.label)}
onPointerDown={(event) => event.stopPropagation()}
type="button"
>
@ -3918,13 +4316,17 @@ function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected:
)}
>
<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="relative text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-white/8 text-white/70">
<Video className="h-7 w-7" />
{mediaUrl ? (
<video className="relative h-full w-full object-cover" controls playsInline src={mediaUrl} />
) : (
<div className="relative text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-white/8 text-white/70">
<Video className="h-7 w-7" />
</div>
<div className="mt-4 text-[18px] font-semibold text-white/78">待生成视频</div>
<div className="mt-2 text-[13px] text-white/38">生成后将在这里预览</div>
</div>
<div className="mt-4 text-[18px] font-semibold text-white/78">待生成视频</div>
<div className="mt-2 text-[13px] text-white/38">生成后将在这里预览</div>
</div>
)}
<button
className="absolute right-3 top-3 rounded-[8px] bg-white/8 px-3 py-2 text-[12px] text-white/72 hover:bg-white/12"
onClick={() => toast.info("视频全屏预览待接入。")}