Load LibTV starter workflows on canvas
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 15:49:06 +08:00
parent c3b4e35997
commit cfd48b6b3b

View File

@ -31,6 +31,7 @@ import { useRouter } from "next/navigation";
import {
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
@ -105,7 +106,7 @@ type WorkflowPreset = {
cover?: string;
};
type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save";
type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save" | "media" | "script";
type CloneNode = {
id: string;
@ -614,6 +615,242 @@ function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: numb
};
}
function getDraftString(node: CloneNode | null | undefined, key: string) {
const value = node?.draftValues?.[key];
return typeof value === "string" ? value : "";
}
function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
const value = getDraftString(node, "targetPreset");
if (
value === "story-script" ||
value === "character-three-view" ||
value === "first-frame" ||
value === "audio-to-video"
) {
return value;
}
return null;
}
function createPresetMediaNode({
action,
generated = false,
height,
mediaType,
mediaUrl,
preset,
prompt,
title,
width,
x,
y,
}: {
action: string;
generated?: boolean;
height: number;
mediaType: NonNullable<CloneNode["inputType"]>;
mediaUrl?: string;
preset: Exclude<ActiveStarterScene, null>;
prompt?: string;
title: string;
width: number;
x: number;
y: number;
}): CloneNode {
return {
id: makeId(mediaType),
kind: "media",
title,
subtitle: generated ? "生成结果" : mediaType === "audio" ? "音频资源" : "素材资源",
inputType: mediaType,
x,
y,
width,
height,
accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
draftValues: {
action,
generated,
mediaType,
mediaUrl: mediaUrl || "",
params: prompt ? { prompt } : undefined,
prompt: prompt || "",
targetPreset: preset,
url: mediaUrl ? [mediaUrl] : [],
},
};
}
function createPresetScriptNode({
preset,
role,
title,
x,
y,
}: {
preset: Exclude<ActiveStarterScene, null>;
role: "resource" | "generator";
title: string;
x: number;
y: number;
}): CloneNode {
const isGenerator = role === "generator";
return {
id: makeId(isGenerator ? "script" : "text"),
kind: "script",
title,
subtitle: isGenerator ? "脚本生成器" : "文本资源",
inputType: "text",
x,
y,
width: 560,
height: 430,
accent: "#f0d06b",
draftValues: {
action: isGenerator ? "SCRIPT_GENERATE" : "TEXT_RESOURCE",
content: isGenerator ? [] : [STORY_SCRIPT_TEXT],
params: isGenerator ? { count: 1, prompt: STORY_SCRIPT_PROMPT } : undefined,
prompt: isGenerator ? STORY_SCRIPT_PROMPT : "",
rows: isGenerator ? [] : undefined,
scriptRole: role,
targetPreset: preset,
viewMode: isGenerator ? "table" : undefined,
},
};
}
function makePresetEdges(leftNodes: CloneNode[], rightNode: CloneNode): CloneEdge[] {
return leftNodes.map((node) => ({
id: makeId("edge"),
source: node.id,
target: rightNode.id,
}));
}
function createStarterWorkflow(
starterTitle: string,
center: { x: number; y: number }
): { nodes: CloneNode[]; edges: CloneEdge[]; selectedId: string } | null {
if (starterTitle === "故事脚本生成") {
const preset = "story-script";
const left = createPresetScriptNode({
preset,
role: "resource",
title: "剧本",
x: Math.round(center.x - 610),
y: Math.round(center.y - 320),
});
const right = createPresetScriptNode({
preset,
role: "generator",
title: "脚本生成器",
x: Math.round(center.x + 50),
y: Math.round(center.y - 320),
});
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
}
if (starterTitle === "角色三视图") {
const preset = "character-three-view";
const distance = 370;
const left = createPresetMediaNode({
action: "IMAGE_RESOURCE",
height: 446,
mediaType: "image",
mediaUrl: CHARACTER_IMAGE_URL,
preset,
title: "角色图",
width: 734,
x: Math.round(center.x - 734 - distance / 2),
y: Math.round(center.y - 320),
});
const right = createPresetMediaNode({
action: "IMAGE_GENERATE",
generated: true,
height: 446,
mediaType: "image",
mediaUrl: CHARACTER_THREE_VIEW_URL,
preset,
title: "角色三视图",
width: 556,
x: Math.round(center.x + distance / 2),
y: Math.round(center.y - 320),
});
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
}
if (starterTitle === "首帧图生视频") {
const preset = "first-frame";
const left = createPresetMediaNode({
action: "IMAGE_RESOURCE",
height: 430,
mediaType: "image",
mediaUrl: FIRST_FRAME_IMAGE_URL,
preset,
title: "首帧",
width: 620,
x: Math.round(center.x - 675),
y: Math.round(center.y - 310),
});
const right = createPresetMediaNode({
action: "VIDEO_GENERATE",
generated: true,
height: 430,
mediaType: "video",
preset,
title: "视频",
width: 560,
x: Math.round(center.x + 55),
y: Math.round(center.y - 310),
});
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
}
if (starterTitle === "音频生视频") {
const preset = "audio-to-video";
const leftX = Math.round(center.x - 510);
const leftY = Math.round(center.y - 320);
const audio = createPresetMediaNode({
action: "AUDIO_RESOURCE",
height: 158,
mediaType: "audio",
mediaUrl: AUDIO_SOURCE_URL,
preset,
title: "音频",
width: 430,
x: leftX,
y: leftY,
});
const image = createPresetMediaNode({
action: "IMAGE_RESOURCE",
height: 252,
mediaType: "image",
mediaUrl: AUDIO_REFERENCE_IMAGE_URL,
preset,
title: "图片",
width: 430,
x: leftX,
y: leftY + 190,
});
const right = createPresetMediaNode({
action: "VIDEO_GENERATE",
generated: true,
height: 430,
mediaType: "video",
preset,
prompt: AUDIO_TO_VIDEO_PROMPT,
title: "视频",
width: 560,
x: Math.round(center.x + 100),
y: Math.round(center.y - 320),
});
return { nodes: [audio, image, right], edges: makePresetEdges([audio, image], right), selectedId: right.id };
}
return null;
}
export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: LiblibVideoCanvasReplicaProps) {
const router = useRouter();
const canvasRef = useRef<HTMLDivElement | null>(null);
@ -626,7 +863,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const [draftTitle, setDraftTitle] = useState("未命名");
const [isTitleEditing, setIsTitleEditing] = useState(false);
const [activePanel, setActivePanel] = useState<ActivePanel>(null);
const [activeStarterScene, setActiveStarterScene] = useState<ActiveStarterScene>(null);
const [toolTab, setToolTab] = useState<(typeof PANEL_TABS)[number]>("全部");
const [toolSearch, setToolSearch] = useState("");
const [isProjectLoading, setIsProjectLoading] = useState(true);
@ -833,7 +1069,6 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
: createSimpleNode(`${TOOL_TYPE_META[type]?.label || "工具"}生成`, type === "text" ? "text" : "model", canvasCommand.canvasX, canvasCommand.canvasY)
);
addNodes([node], []);
setActiveStarterScene(null);
setCanvasCommand(null);
toast.success(`已添加 ${node.title}`);
},
@ -886,23 +1121,21 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const createStarter = useCallback(
(starter: (typeof STARTER_CARDS)[number]) => {
const starterScenes: Record<string, Exclude<ActiveStarterScene, null>> = {
故事脚本生成: "story-script",
角色三视图: "character-three-view",
首帧图生视频: "first-frame",
音频生视频: "audio-to-video",
};
const starterScene = starterScenes[starter.title];
if (starterScene) {
setNodes([]);
setEdges([]);
setSelectedNodeId(null);
const center =
typeof window === "undefined"
? { x: 1000, y: 520 }
: toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
const workflow = createStarterWorkflow(starter.title, center);
if (workflow) {
addNodes(workflow.nodes, workflow.edges);
setSelectedNodeId(workflow.selectedId);
setCanvasCommand(null);
setNodeContextMenu(null);
setActivePanel(null);
setActiveStarterScene(starterScene);
toast.success(`已载入 ${starter.title} 工作流`);
return;
}
setActiveStarterScene(null);
const baseX = 380;
const baseY = 325;
const newNodes = starter.steps.map((title, index) => {
@ -923,7 +1156,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
}));
addNodes(newNodes, newEdges);
},
[addNodes]
[addNodes, toCanvasPoint]
);
const updateNodeDraft = useCallback((key: string, value: unknown) => {
@ -971,7 +1204,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId && !activeStarterScene) {
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId) {
event.preventDefault();
deleteNode(selectedNodeId);
}
@ -982,7 +1215,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [activeStarterScene, deleteNode, selectedNodeId]);
}, [deleteNode, selectedNodeId]);
const onPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@ -1037,6 +1270,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
}, []);
const isBusy = isLoading || isProjectLoading;
const selectedPresetMode = getPresetMode(selectedNode);
return (
<div
@ -1066,12 +1300,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
))}
</div>
{activeStarterScene === "story-script" ? <StoryScriptScene /> : null}
{activeStarterScene === "character-three-view" ? <CharacterThreeViewScene /> : null}
{activeStarterScene === "first-frame" ? <FirstFrameVideoScene /> : null}
{activeStarterScene === "audio-to-video" ? <AudioToVideoScene /> : null}
{nodes.length === 0 && !activePanel && !activeStarterScene ? (
{nodes.length === 0 && !activePanel ? (
<div
className="-translate-x-1/2 -translate-y-1/2 absolute left-1/2 top-[51%] z-20 w-[900px] max-w-[calc(100vw-180px)]"
data-canvas-overlay="true"
@ -1139,31 +1368,29 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
/>
) : null}
{!activeStarterScene ? (
<>
<TopBar
draftTitle={draftTitle}
isSaving={isSaving}
isTitleEditing={isTitleEditing}
onAccount={() => toggleTopPanel("account")}
onBell={() => toggleTopPanel("notifications")}
onCommitTitle={commitTitle}
onDraftTitleChange={setDraftTitle}
onMember={() => toggleTopPanel("member")}
onSave={saveCanvas}
onShare={() => toggleTopPanel("share")}
onSkills={() => toggleTopPanel("skills")}
onStartTitleEdit={() => setIsTitleEditing(true)}
projectTitle={projectTitle}
routerBack={() => router.push("/")}
/>
{selectedPresetMode ? <PresetWorkflowControls mode={selectedPresetMode} /> : null}
<LeftToolbar activePanel={activePanel} onOpen={(panel) => setActivePanel((current) => current === panel ? null : panel)} />
</>
) : null}
<TopBar
draftTitle={draftTitle}
isSaving={isSaving}
isTitleEditing={isTitleEditing}
onAccount={() => toggleTopPanel("account")}
onBell={() => toggleTopPanel("notifications")}
onCommitTitle={commitTitle}
onDraftTitleChange={setDraftTitle}
onMember={() => toggleTopPanel("member")}
onSave={saveCanvas}
onShare={() => toggleTopPanel("share")}
onSkills={() => toggleTopPanel("skills")}
onStartTitleEdit={() => setIsTitleEditing(true)}
projectTitle={projectTitle}
routerBack={() => router.push("/")}
/>
<LeftToolbar activePanel={activePanel} onOpen={(panel) => setActivePanel((current) => current === panel ? null : panel)} />
<ZoomDock
compact={Boolean(activeStarterScene)}
compact={false}
zoom={viewport.zoom}
onCenter={() => setViewport(DEFAULT_VIEWPORT)}
onZoomIn={() => zoomBy(0.1)}
@ -1190,7 +1417,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
/>
) : null}
{selectedNode ? (
{selectedNode && !selectedPresetMode ? (
<InspectorPanel
node={selectedNode}
onChangeProperty={updateNodeDraft}
@ -1670,6 +1897,109 @@ function MagnetGlyph() {
);
}
function PresetWorkflowControls({ mode }: { mode: Exclude<ActiveStarterScene, null> }) {
if (mode === "character-three-view") {
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<CharacterPresetToolbar />
<PromptComposer />
</div>
);
}
if (mode === "story-script") {
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<PresetFloatingToolbar
items={[
["拆分场景", "按镜号拆分脚本接口待接入。"],
["生成分镜", "分镜生成接口待接入。"],
["表格视图", "脚本表格视图接口待接入。"],
["导出", "脚本导出接口待接入。"],
]}
/>
<PresetComposerPanel
credit="8"
prompt={STORY_SCRIPT_PROMPT}
submitMessage="故事脚本生成接口待接入。"
title="脚本生成器"
/>
</div>
);
}
if (mode === "first-frame") {
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<PresetFloatingToolbar
items={[
["高清", "首帧高清接口待接入。"],
["扩图", "首帧扩图接口待接入。"],
["替换背景", "背景替换接口待接入。"],
["下载", "图片下载接口待接入。"],
]}
/>
<PresetComposerPanel
credit="28"
prompt="以当前图为首帧生成视频。"
submitMessage="首帧图生视频接口待接入。"
thumbnailUrl={FIRST_FRAME_IMAGE_URL}
title="首帧图生视频"
/>
</div>
);
}
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<PresetFloatingToolbar
items={[
["节奏匹配", "音频节奏解析接口待接入。"],
["镜头语言", "镜头语言配置接口待接入。"],
["生成音效", "音效生成接口待接入。"],
["下载", "素材下载接口待接入。"],
]}
/>
<PresetComposerPanel
credit="32"
prompt={AUDIO_TO_VIDEO_PROMPT}
submitMessage="音频生视频接口待接入。"
thumbnailUrl={AUDIO_REFERENCE_IMAGE_URL}
title="音频生视频"
/>
</div>
);
}
function CharacterPresetToolbar() {
const [toolbarMenu, setToolbarMenu] = useState<SceneToolbarMenu>(null);
return (
<>
<div className="pointer-events-auto absolute left-1/2 top-[90px] flex h-[72px] -translate-x-1/2 items-center gap-6 rounded-[18px] border border-white/8 bg-[#262626]/96 px-7 text-[18px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
<SceneTool active={toolbarMenu === "panorama"} kind="panorama" label="全景" badge="NEW" onClick={() => setToolbarMenu((current) => current === "panorama" ? null : "panorama")} />
<SceneTool active={toolbarMenu === "orbit"} kind="orbit" label="多角度" onClick={() => setToolbarMenu((current) => current === "orbit" ? null : "orbit")} />
<SceneTool active={toolbarMenu === "lighting"} kind="lighting" label="打光" onClick={() => setToolbarMenu((current) => current === "lighting" ? null : "lighting")} />
<SceneTool active={toolbarMenu === "grid"} kind="grid" label="九宫格" dropdown onClick={() => setToolbarMenu((current) => current === "grid" ? null : "grid")} />
<SceneTool active={toolbarMenu === "hd"} kind="hd" label="高清" dropdown onClick={() => setToolbarMenu((current) => current === "hd" ? null : "hd")} />
<SceneTool active={toolbarMenu === "crop"} kind="crop" label="宫格切分" dropdown onClick={() => setToolbarMenu((current) => current === "crop" ? null : "crop")} />
<div className="h-9 w-px bg-white/8" />
{["⌁", "↔", "↓", "↗"].map((item, index) => (
<button
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-[28px] text-white/84 hover:bg-white/8"
key={item}
onClick={() => toast.info(["绘制编辑待接入。", "对比预览待接入。", "下载素材待接入。", "全屏预览待接入。"][index])}
type="button"
>
{item}
</button>
))}
</div>
{toolbarMenu ? <SceneToolbarPopover menu={toolbarMenu} onClose={() => setToolbarMenu(null)} /> : null}
</>
);
}
function StoryScriptScene() {
return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
@ -2546,35 +2876,47 @@ function CloneCanvasNode({
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
selected: boolean;
}) {
if (node.kind === "media") {
return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
<PresetMediaCanvasNode node={node} selected={selected} />
</CanvasNodeShell>
);
}
if (node.kind === "script") {
return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
<PresetScriptCanvasNode node={node} selected={selected} />
</CanvasNodeShell>
);
}
const Icon = node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon;
const isInput = node.kind === "input";
const isWarm = node.kind === "text" || node.kind === "audio";
const previewUrl = getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
return (
<div
className={cn(
"absolute cursor-grab select-none shadow-[0_14px_34px_rgba(0,0,0,0.35)] active:cursor-grabbing",
selected && "z-20"
)}
data-canvas-overlay="true"
onContextMenu={onContextMenu}
onPointerDown={onPointerDown}
style={{ height: node.height, left: node.x, top: node.y, width: node.width }}
>
<span className="absolute left-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
<span className="absolute right-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
{isInput ? (
<div
className={cn(
"flex h-full w-full flex-col items-center justify-center rounded-[4px] bg-[#f8f9ff] text-center text-[#2a2d3a]",
"relative flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-[4px] bg-[#f8f9ff] text-center text-[#2a2d3a]",
selected && "outline outline-2 outline-[#8b96ff]"
)}
>
<span className="mb-3 flex h-8 w-8 items-center justify-center rounded-[8px] border border-[#dfe3ff] bg-white text-[#7a85ff]">
{previewUrl ? (
<>
<img alt="" className="absolute inset-0 h-full w-full object-cover opacity-28" src={previewUrl} />
<span className="absolute inset-0 bg-white/62" />
</>
) : null}
<span className="relative mb-3 flex h-8 w-8 items-center justify-center rounded-[8px] border border-[#dfe3ff] bg-white text-[#7a85ff]">
<Icon className="h-4 w-4" />
</span>
<div className="font-semibold text-[14px]">{node.title}</div>
<div className="mt-2 line-clamp-3 px-5 text-[11px] leading-5 text-[#6f7482]">{node.subtitle}</div>
<button className="mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]" type="button">
<div className="relative font-semibold text-[14px]">{node.title}</div>
<div className="relative mt-2 line-clamp-3 px-5 text-[11px] leading-5 text-[#6f7482]">{node.subtitle}</div>
<button className="relative mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]" type="button">
选择文件
</button>
</div>
@ -2597,13 +2939,212 @@ function CloneCanvasNode({
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 text-[12px] text-[#697080]">{node.subtitle || "工具"}</div>
<div className="truncate font-semibold text-[20px] tracking-tight">{node.title}</div>
<div className="truncate font-semibold text-[20px] tracking-[0]">{node.title}</div>
</div>
<div className="flex h-[58px] w-8 items-center justify-center rounded-full border border-black/8 bg-white/62 text-center text-[13px] leading-4 text-[#272a32]">
待<br />配置
</div>
</div>
)}
</CanvasNodeShell>
);
}
function CanvasNodeShell({
children,
node,
onContextMenu,
onPointerDown,
selected,
}: {
children: ReactNode;
node: CloneNode;
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
selected: boolean;
}) {
return (
<div
className={cn(
"absolute cursor-grab select-none shadow-[0_14px_34px_rgba(0,0,0,0.35)] active:cursor-grabbing",
selected && "z-20"
)}
data-canvas-overlay="true"
onContextMenu={onContextMenu}
onPointerDown={onPointerDown}
style={{ height: node.height, left: node.x, top: node.y, width: node.width }}
>
<span className="absolute left-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
<span className="absolute right-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 rounded-full border border-white/30 bg-white" />
{children}
</div>
);
}
function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
const mediaUrl = getDraftString(node, "mediaUrl");
const generated = Boolean(node.draftValues?.generated);
const Icon = mediaType === "audio" ? Music2 : mediaType === "video" ? Video : ImageIcon;
return (
<div className="h-full w-full">
<div className="mb-2 flex h-6 items-center gap-1 text-[20px] font-semibold text-white/50">
<Icon className="h-5 w-5" />
{node.title}
</div>
{mediaType === "audio" ? (
<div
className={cn(
"h-[calc(100%-32px)] rounded-[10px] border border-white/12 bg-[#f4f5f7] p-4 text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
selected && "outline outline-2 outline-[#8b96ff]"
)}
>
<div className="flex items-center gap-4">
<button
className="flex h-12 w-12 items-center justify-center rounded-full bg-[#111] text-white"
onClick={() => toast.info("音频播放控制待接入。")}
type="button"
>
▶
</button>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-semibold">情绪配乐.mp3</div>
<div className="mt-1 text-[12px] text-[#697080]">音频生视频参考素材</div>
</div>
{mediaUrl ? (
<a className="rounded-[8px] px-2 py-1 text-[12px] text-[#4f5cff] hover:bg-black/5" href={mediaUrl} rel="noreferrer" target="_blank">
试听
</a>
) : null}
</div>
<div className="mt-4 flex h-12 items-end gap-1">
{Array.from({ length: 38 }).map((_, index) => (
<span
className="flex-1 rounded-full bg-[#7b86ff]"
key={index}
style={{ height: `${18 + ((index * 17) % 28)}px`, opacity: 0.34 + ((index % 5) * 0.12) }}
/>
))}
</div>
</div>
) : mediaType === "video" ? (
<div
className={cn(
"relative flex h-[calc(100%-32px)] items-center justify-center overflow-hidden rounded-[10px] border border-white/12 bg-[#101010] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
selected && "outline outline-2 outline-[#8b96ff]"
)}
>
<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" />
</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("视频全屏预览待接入。")}
type="button"
>
↗
</button>
</div>
) : (
<div
className={cn(
"relative h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-white shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
selected && "outline outline-2 outline-[#8b96ff]"
)}
>
{mediaUrl ? (
<img
alt={node.title}
className={cn("h-full w-full", node.title === "角色三视图" ? "object-contain" : "object-cover")}
src={mediaUrl}
/>
) : (
<div className="flex h-full items-center justify-center text-[13px] text-black/40">等待图片</div>
)}
{!generated ? (
<button
className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-[8px] bg-black/56 text-white/90"
onClick={() => toast.info(`${node.title}素材替换接口待接入。`)}
type="button"
>
↥
</button>
) : null}
</div>
)}
</div>
);
}
function PresetScriptCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
const scriptRole = getDraftString(node, "scriptRole");
const rows = [
["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"],
["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"],
["03", "长安街市", "诗声落下,盛唐山河展开", "史诗音乐起"],
];
return (
<div className="h-full w-full">
<div className="mb-2 flex h-6 items-center gap-1 text-[20px] font-semibold text-white/50">
{scriptRole === "generator" ? <GridTilesGlyph /> : <FileText className="h-5 w-5" />}
{node.title}
</div>
{scriptRole === "generator" ? (
<div
className={cn(
"h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-white text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
selected && "outline outline-2 outline-[#8b96ff]"
)}
>
<div className="flex h-12 items-center justify-between border-[#e5e7eb] border-b px-4">
<span className="font-semibold text-[15px]">故事脚本</span>
<span className="rounded-full bg-[#eef0ff] px-3 py-1 text-[12px] text-[#5161ff]">待生成</span>
</div>
<div className="grid grid-cols-[64px_120px_1fr_110px] border-[#eef0f3] border-b bg-[#f7f8fa] text-[12px] text-[#697080]">
{["镜号", "场景", "画面", "声音"].map((item) => (
<div className="border-[#eef0f3] border-r px-3 py-2 last:border-r-0" key={item}>{item}</div>
))}
</div>
{rows.map((row) => (
<div className="grid grid-cols-[64px_120px_1fr_110px] border-[#eef0f3] border-b text-[12px]" key={row[0]}>
{row.map((cell) => (
<div className="border-[#eef0f3] border-r px-3 py-3 last:border-r-0" key={cell}>{cell}</div>
))}
</div>
))}
<button
className="m-4 h-10 w-[calc(100%-32px)] rounded-[10px] bg-[#111] text-[13px] font-semibold text-white"
onClick={() => toast.info("故事脚本生成接口待接入。")}
type="button"
>
生成完整脚本
</button>
</div>
) : (
<div
className={cn(
"h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-[#f4f5f7] text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
selected && "outline outline-2 outline-[#8b96ff]"
)}
>
<div className="flex h-11 items-center justify-between border-[#dfe2e8] border-b px-4 text-[13px] text-[#697080]">
<span>输入内容</span>
<button className="rounded-[8px] px-2 py-1 text-[#4f5cff] hover:bg-black/5" onClick={() => toast.info("编辑剧本接口待接入。")} type="button">
编辑
</button>
</div>
<div className="h-[calc(100%-44px)] overflow-y-auto whitespace-pre-wrap px-5 py-4 text-[14px] leading-7">
{STORY_SCRIPT_TEXT}
</div>
</div>
)}
</div>
);
}