2838 lines
112 KiB
TypeScript
2838 lines
112 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Bell,
|
||
ChevronDown,
|
||
Clock3,
|
||
Copy,
|
||
Crown,
|
||
FileText,
|
||
GitBranch,
|
||
Headphones,
|
||
HelpCircle,
|
||
Image as ImageIcon,
|
||
Layers,
|
||
Loader2,
|
||
Music2,
|
||
Plus,
|
||
Save,
|
||
Search,
|
||
Share2,
|
||
Sparkles,
|
||
UserRound,
|
||
Video,
|
||
X,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
type PointerEvent as ReactPointerEvent,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
|
||
import { cn } from "@/lib/utils";
|
||
import { canvasService } from "@/services/canvas";
|
||
|
||
type ActivePanel =
|
||
| "add"
|
||
| "workflow"
|
||
| "assets"
|
||
| "history"
|
||
| "help"
|
||
| "support"
|
||
| "skills"
|
||
| "share"
|
||
| "publish"
|
||
| "member"
|
||
| "notifications"
|
||
| "account"
|
||
| null;
|
||
|
||
type ActiveStarterScene = "story-script" | "character-three-view" | "first-frame" | "audio-to-video" | null;
|
||
type SceneToolbarMenu = "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop" | null;
|
||
type ComposerMenu =
|
||
| "style"
|
||
| "mark"
|
||
| "focus"
|
||
| "model"
|
||
| "ratio"
|
||
| "resolution"
|
||
| "camera"
|
||
| "panorama"
|
||
| "translate"
|
||
| "settings"
|
||
| "count"
|
||
| null;
|
||
|
||
type CapturedTool = {
|
||
type?: string;
|
||
toolKey?: string;
|
||
modelKey?: string;
|
||
modelName?: string;
|
||
vendor?: string;
|
||
scene?: string;
|
||
modes?: string[];
|
||
settings?: string[];
|
||
advancedSettings?: string[];
|
||
propertyKeys?: string[];
|
||
generateTypes?: unknown;
|
||
rules?: unknown;
|
||
metadata?: {
|
||
properties?: Record<string, unknown>;
|
||
config?: Record<string, unknown>;
|
||
generateTypes?: unknown;
|
||
rules?: unknown;
|
||
[key: string]: unknown;
|
||
};
|
||
};
|
||
|
||
type WorkflowPreset = {
|
||
id?: string | number;
|
||
uuid?: string;
|
||
name?: string;
|
||
title?: string;
|
||
description?: string;
|
||
cover?: string;
|
||
};
|
||
|
||
type CloneNodeKind = "input" | "model" | "text" | "audio" | "workflow" | "preview" | "save";
|
||
|
||
type CloneNode = {
|
||
id: string;
|
||
kind: CloneNodeKind;
|
||
title: string;
|
||
subtitle?: string;
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
accent: string;
|
||
tool?: CapturedTool;
|
||
workflow?: WorkflowPreset;
|
||
inputType?: "image" | "video" | "audio" | "text";
|
||
draftValues?: Record<string, unknown>;
|
||
};
|
||
|
||
type CloneEdge = {
|
||
id: string;
|
||
source: string;
|
||
target: string;
|
||
};
|
||
|
||
type DragState =
|
||
| {
|
||
kind: "node";
|
||
id: string;
|
||
startX: number;
|
||
startY: number;
|
||
nodeX: number;
|
||
nodeY: number;
|
||
}
|
||
| {
|
||
kind: "canvas";
|
||
startX: number;
|
||
startY: number;
|
||
viewportX: number;
|
||
viewportY: number;
|
||
};
|
||
|
||
type LiblibVideoCanvasReplicaProps = {
|
||
canvasId: string;
|
||
isLoading?: boolean;
|
||
};
|
||
|
||
const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[];
|
||
const capturedCounts = (capturedCanvasModules as any).counts || {};
|
||
const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[];
|
||
|
||
const TOOL_TYPE_META: Record<
|
||
string,
|
||
{ label: string; icon: LucideIcon; accent: string; inputType?: CloneNode["inputType"] }
|
||
> = {
|
||
image: { label: "图片", icon: ImageIcon, accent: "#8b96ff", inputType: "image" },
|
||
video: { label: "视频", icon: Video, accent: "#8b96ff", inputType: "video" },
|
||
text: { label: "文本", icon: FileText, accent: "#f0d06b", inputType: "text" },
|
||
audio: { label: "音频", icon: Music2, accent: "#9ee6bb", inputType: "audio" },
|
||
compose: { label: "组合", icon: GitBranch, accent: "#ffb8c8" },
|
||
};
|
||
|
||
const PANEL_TABS = ["全部", "图片", "视频", "文本", "音频", "组合"] as const;
|
||
|
||
const STARTER_CARDS = [
|
||
{
|
||
title: "故事脚本生成",
|
||
subtitle: "脚本到分镜",
|
||
coverUrl:
|
||
"https://liblibai-online.liblib.cloud/web/ef0097ae9da74189a0f5d604e98c77cd/f9d23335-4ec2-412d-9954-91c58f4b5eb3.png",
|
||
steps: ["故事脚本", "多模态文本模型Pro", "可灵 3.0", "预览"],
|
||
},
|
||
{
|
||
title: "角色三视图",
|
||
subtitle: "角色资产沉淀",
|
||
coverUrl:
|
||
"https://liblibai-online.liblib.cloud/web/ef0097ae9da74189a0f5d604e98c77cd/e3731aeb-a95b-46e9-99e7-3cd7f22a1e3f.png",
|
||
steps: ["角色描述", "角色三视图", "全能图片模型V2", "保存"],
|
||
},
|
||
{
|
||
title: "首帧图生视频",
|
||
subtitle: "参考图到成片",
|
||
coverUrl:
|
||
"https://liblibai-online.liblib.cloud/web/ef0097ae9da74189a0f5d604e98c77cd/f407cb9c-8dd7-436b-8e80-88da1cebf25d.png",
|
||
steps: ["上传图片", "首帧图", "可灵 3.0", "预览"],
|
||
},
|
||
{
|
||
title: "音频生视频",
|
||
subtitle: "音频驱动画面",
|
||
coverUrl:
|
||
"https://liblibai-online.liblib.cloud/web/ef0097ae9da74189a0f5d604e98c77cd/bd88df14-04bd-473d-9016-ec72a5273bfb.png",
|
||
icon: "music",
|
||
steps: ["上传音频", "音频主视频", "Wan 2.6", "预览"],
|
||
},
|
||
];
|
||
|
||
const TOOLBAR_ITEMS: Array<{ id: Exclude<ActivePanel, null>; label: string; icon: LucideIcon }> = [
|
||
{ id: "add", label: "添加模块", icon: Plus },
|
||
{ id: "workflow", label: "工作流", icon: GitBranch },
|
||
{ id: "assets", label: "素材", icon: Layers },
|
||
{ id: "history", label: "历史", icon: Clock3 },
|
||
{ id: "help", label: "帮助", icon: HelpCircle },
|
||
{ id: "support", label: "客服", icon: Headphones },
|
||
];
|
||
|
||
const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 };
|
||
|
||
const TARGET_AVATAR =
|
||
"https://liblibai-online.liblib.cloud/web/1656e2f1b3c74f1ba823b610f1b2cbdf/0c3eb5e2-1dc0-405c-a823-07b2570237f8.jpg?x-oss-process=image/resize,w_100,m_lfit/format,webp";
|
||
const CHARACTER_IMAGE_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/cca9e085-dccc-4f72-aef7-d68dc6688bcf.png";
|
||
const CHARACTER_THREE_VIEW_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/7a32ba0b-7b5b-46b1-a86c-e0904482adf7.png";
|
||
const FIRST_FRAME_IMAGE_URL =
|
||
"https://liblibai-online.liblib.cloud/web/28aa80df5944499487381983a8f9bbdd/da241fab-3569-49aa-ae4e-c20de9f718e0.png";
|
||
const AUDIO_SOURCE_URL =
|
||
"https://liblibai-online.liblib.cloud/web/28aa80df5944499487381983a8f9bbdd/3ca0886d-d35f-4048-95bf-cc655e9c57fd.mp3";
|
||
const AUDIO_REFERENCE_IMAGE_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/1342d344-2d95-41a1-a2aa-4d20869a318c.png";
|
||
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
|
||
const AUDIO_TO_VIDEO_PROMPT =
|
||
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
|
||
const STORY_SCRIPT_TEXT = `《我在盛唐写天下》
|
||
|
||
类型:古风 / 穿越 / 爽文漫剧
|
||
时长建议:60-90秒
|
||
基调:热血 x 盛唐史诗感 x 爽点节奏
|
||
|
||
【序幕】
|
||
【现代 · 深夜办公室】
|
||
键盘声急促。电脑屏幕蓝光刺眼。沈昭昭(女,28岁),面色苍白,伏案加班。手机提示音不断。她抬头,视线模糊,黑屏。
|
||
|
||
【第一幕】
|
||
【盛唐 · 金銮殿 · 日】
|
||
鼓声震天。沈昭昭猛然睁眼,已跪在大殿中央,身着男装官服,双手被押。百官列立,殿宇巍峨。
|
||
大臣厉声:“大逆不道!假称诗才惊世,欺君罔上,当斩!”
|
||
沈昭昭震惊低语:“这是……唐朝?”
|
||
龙椅之上,皇帝目光沉沉。
|
||
皇帝:“既言才华盖世,当殿作诗。若不能,斩。”
|
||
刀光映入她瞳孔。大殿死寂。
|
||
|
||
【第二幕】
|
||
沈昭昭缓缓起身。呼吸渐稳。她望向殿外,长安城阳光万里。
|
||
她低声:“既来盛唐……便与盛唐争一争。”
|
||
抬头,高声吟诵:“君不见黄河之水天上来。”
|
||
声音回荡大殿。群臣震动。皇帝缓缓站起。诗声激昂,如惊雷落殿。
|
||
|
||
【第三幕】
|
||
殿外风起。长安街市灯火连绵。
|
||
皇帝低声:“此诗,当传万世。”
|
||
沈昭昭目光坚定。她轻声自语:“既然来了……那便写尽三万里长安。”
|
||
镜头推远。盛唐山河展开。`;
|
||
|
||
function normalizeCanvasTitle(title?: string | null) {
|
||
const value = String(title || "").trim();
|
||
if (!value || value === "未命名视频画布") return "未命名";
|
||
return value;
|
||
}
|
||
|
||
function getToolType(tool?: CapturedTool) {
|
||
return String(tool?.type || "video").trim() || "video";
|
||
}
|
||
|
||
function getToolLabel(tool?: CapturedTool) {
|
||
return String(tool?.modelName || tool?.scene || tool?.modelKey || tool?.toolKey || "未命名模块").trim();
|
||
}
|
||
|
||
function getToolModes(tool?: CapturedTool) {
|
||
return (tool?.modes || []).map(String).filter(Boolean).slice(0, 4);
|
||
}
|
||
|
||
function getToolProperties(tool?: CapturedTool) {
|
||
const properties = tool?.metadata?.properties;
|
||
if (!properties || typeof properties !== "object") return [];
|
||
return Object.entries(properties).filter(([key]) => key !== "modeType");
|
||
}
|
||
|
||
function getToolRules(tool?: CapturedTool) {
|
||
const rules = tool?.rules || tool?.metadata?.rules;
|
||
return Array.isArray(rules) ? rules : [];
|
||
}
|
||
|
||
function requiresMedia(tool?: CapturedTool) {
|
||
return getToolRules(tool).some((rule) => {
|
||
if (!rule || typeof rule !== "object") return false;
|
||
const required = (rule as { require?: unknown }).require;
|
||
return Array.isArray(required) && required.map(String).includes("media");
|
||
});
|
||
}
|
||
|
||
function getPropertyLabel(key: string, config: unknown) {
|
||
if (config && typeof config === "object" && "displayName" in config) {
|
||
const value = (config as { displayName?: unknown }).displayName;
|
||
if (typeof value === "string" && value.trim()) return value;
|
||
}
|
||
const labels: Record<string, string> = {
|
||
count: "生成数量",
|
||
duration: "时长",
|
||
enableSound: "生成音效",
|
||
frameRate: "帧率",
|
||
instrumental: "纯音乐",
|
||
lora: "风格",
|
||
prompt: "提示词",
|
||
quality: "清晰度",
|
||
ratio: "比例",
|
||
resolution: "分辨率",
|
||
searchable: "联网搜索",
|
||
search_enabled: "联网搜索",
|
||
slowMotion: "慢动作",
|
||
slowmo: "减速强度",
|
||
specifiedModel: "模型",
|
||
stability: "稳定性",
|
||
style: "模型",
|
||
template: "风格",
|
||
voice: "声音",
|
||
};
|
||
return labels[key] || key;
|
||
}
|
||
|
||
function getOptions(config: unknown) {
|
||
if (Array.isArray(config)) return config;
|
||
if (config && typeof config === "object" && Array.isArray((config as { enum?: unknown[] }).enum)) {
|
||
return (config as { enum: unknown[] }).enum;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function getOptionValue(option: unknown) {
|
||
if (option && typeof option === "object" && "value" in option) {
|
||
return String((option as { value?: unknown }).value ?? "");
|
||
}
|
||
return String(option ?? "");
|
||
}
|
||
|
||
function getOptionLabel(option: unknown) {
|
||
if (option && typeof option === "object" && "displayName" in option) {
|
||
const label = (option as { displayName?: unknown }).displayName;
|
||
if (label) return String(label);
|
||
}
|
||
return getOptionValue(option);
|
||
}
|
||
|
||
function getDefaultValue(key: string, config: unknown) {
|
||
if (config && typeof config === "object" && "default" in config) {
|
||
return String((config as { default?: unknown }).default ?? "");
|
||
}
|
||
const options = getOptions(config);
|
||
if (options.length > 0) return getOptionValue(options[0]);
|
||
if (typeof config === "boolean") return config ? "1" : "0";
|
||
if (key === "prompt") return "";
|
||
return "";
|
||
}
|
||
|
||
function makeId(prefix: string) {
|
||
return `${prefix}-${crypto.randomUUID()}`;
|
||
}
|
||
|
||
function serializeNodes(nodes: CloneNode[]) {
|
||
return nodes.map((node) => ({
|
||
id: node.id,
|
||
type: "workflow",
|
||
position: { x: node.x, y: node.y },
|
||
size: { width: node.width, height: node.height },
|
||
data: {
|
||
title: node.title,
|
||
description: node.subtitle || "",
|
||
status: "pending",
|
||
templateType: `libtv-${node.kind}`,
|
||
metadata: {
|
||
liblibCloneNode: node,
|
||
},
|
||
},
|
||
connections: [],
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
}));
|
||
}
|
||
|
||
function serializeEdges(edges: CloneEdge[]) {
|
||
return edges.map((edge) => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
type: "default",
|
||
}));
|
||
}
|
||
|
||
function restoreNodes(canvasData: unknown): CloneNode[] {
|
||
const nodes = (canvasData as { nodes?: unknown[] } | null)?.nodes;
|
||
if (!Array.isArray(nodes)) return [];
|
||
return nodes
|
||
.map((item) => {
|
||
const metadata = ((item as any)?.data?.metadata || {}) as Record<string, unknown>;
|
||
const clone = metadata.liblibCloneNode as CloneNode | undefined;
|
||
return clone && typeof clone.id === "string" ? clone : null;
|
||
})
|
||
.filter(Boolean) as CloneNode[];
|
||
}
|
||
|
||
function restoreEdges(canvasData: unknown): CloneEdge[] {
|
||
const edges = (canvasData as { edges?: unknown[] } | null)?.edges;
|
||
if (!Array.isArray(edges)) return [];
|
||
return edges
|
||
.map((edge) => ({
|
||
id: String((edge as any).id || makeId("edge")),
|
||
source: String((edge as any).source || ""),
|
||
target: String((edge as any).target || ""),
|
||
}))
|
||
.filter((edge) => edge.source && edge.target);
|
||
}
|
||
|
||
function createInputNode(inputType: CloneNode["inputType"], x: number, y: number): CloneNode {
|
||
const title =
|
||
inputType === "video"
|
||
? "上传视频"
|
||
: inputType === "audio"
|
||
? "上传音频"
|
||
: inputType === "text"
|
||
? "输入文本"
|
||
: "上传图片";
|
||
return {
|
||
id: makeId("input"),
|
||
kind: "input",
|
||
title,
|
||
subtitle:
|
||
inputType === "video"
|
||
? "添加视频素材、参考视频或首尾帧"
|
||
: inputType === "audio"
|
||
? "添加音乐、语音或音效参考"
|
||
: inputType === "text"
|
||
? "输入脚本、分镜或提示词"
|
||
: "添加参考图、角色图或关键帧素材",
|
||
inputType,
|
||
x,
|
||
y,
|
||
width: 158,
|
||
height: 158,
|
||
accent: "#8790ff",
|
||
};
|
||
}
|
||
|
||
function createToolNode(tool: CapturedTool, x: number, y: number): CloneNode {
|
||
const type = getToolType(tool);
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
const isAudio = type === "audio";
|
||
const isText = type === "text";
|
||
return {
|
||
id: makeId("tool"),
|
||
kind: isAudio ? "audio" : isText ? "text" : "model",
|
||
title: getToolLabel(tool),
|
||
subtitle: tool.vendor || meta.label,
|
||
x,
|
||
y,
|
||
width: isText ? 210 : 194,
|
||
height: isText ? 122 : 116,
|
||
accent: meta.accent,
|
||
tool,
|
||
draftValues: {},
|
||
};
|
||
}
|
||
|
||
function createWorkflowNode(workflow: WorkflowPreset, x: number, y: number): CloneNode {
|
||
return {
|
||
id: makeId("workflow"),
|
||
kind: "workflow",
|
||
title: String(workflow.name || workflow.title || "工作流预设"),
|
||
subtitle: workflow.description || "LibTV workflow",
|
||
x,
|
||
y,
|
||
width: 230,
|
||
height: 116,
|
||
accent: "#8b96ff",
|
||
workflow,
|
||
draftValues: {},
|
||
};
|
||
}
|
||
|
||
function createSimpleNode(title: string, kind: CloneNodeKind, x: number, y: number): CloneNode {
|
||
return {
|
||
id: makeId(kind),
|
||
kind,
|
||
title,
|
||
subtitle: kind === "preview" ? "预览结果" : kind === "save" ? "保存到本地" : "输入内容",
|
||
x,
|
||
y,
|
||
width: kind === "text" ? 210 : 194,
|
||
height: kind === "text" ? 122 : 116,
|
||
accent: kind === "save" || kind === "preview" ? "#8b96ff" : "#f0d06b",
|
||
inputType: kind === "text" ? "text" : undefined,
|
||
draftValues: {},
|
||
};
|
||
}
|
||
|
||
export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: LiblibVideoCanvasReplicaProps) {
|
||
const router = useRouter();
|
||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||
const dragRef = useRef<DragState | null>(null);
|
||
const [nodes, setNodes] = useState<CloneNode[]>([]);
|
||
const [edges, setEdges] = useState<CloneEdge[]>([]);
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
const [viewport, setViewport] = useState(DEFAULT_VIEWPORT);
|
||
const [projectTitle, setProjectTitle] = useState("未命名");
|
||
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);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [shareEnabled, setShareEnabled] = useState(false);
|
||
const [publishPublic, setPublishPublic] = useState(false);
|
||
|
||
const selectedNode = useMemo(
|
||
() => nodes.find((node) => node.id === selectedNodeId) || null,
|
||
[nodes, selectedNodeId]
|
||
);
|
||
|
||
const filteredTools = useMemo(() => {
|
||
const search = toolSearch.trim().toLowerCase();
|
||
return capturedTools.filter((tool) => {
|
||
const type = getToolType(tool);
|
||
const tabMatched =
|
||
toolTab === "全部" ||
|
||
(toolTab === "图片" && type === "image") ||
|
||
(toolTab === "视频" && type === "video") ||
|
||
(toolTab === "文本" && type === "text") ||
|
||
(toolTab === "音频" && type === "audio") ||
|
||
(toolTab === "组合" && type === "compose");
|
||
if (!tabMatched) return false;
|
||
if (!search) return true;
|
||
return [tool.modelName, tool.vendor, tool.scene, tool.modelKey, tool.toolKey, ...(tool.modes || [])]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.toLowerCase()
|
||
.includes(search);
|
||
});
|
||
}, [toolSearch, toolTab]);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
|
||
async function loadProject() {
|
||
setIsProjectLoading(true);
|
||
const result = await canvasService.getProject(canvasId);
|
||
if (!active) return;
|
||
if (result.success && result.data) {
|
||
const title = normalizeCanvasTitle(result.data.title);
|
||
setProjectTitle(title);
|
||
setDraftTitle(title);
|
||
const restoredNodes = restoreNodes(result.data.canvasData);
|
||
const restoredEdges = restoreEdges(result.data.canvasData);
|
||
setNodes(restoredNodes);
|
||
setEdges(restoredEdges);
|
||
} else {
|
||
setProjectTitle("未命名");
|
||
setDraftTitle("未命名");
|
||
await canvasService.saveCanvasState(
|
||
canvasId,
|
||
{
|
||
nodes: [],
|
||
edges: [],
|
||
selectedNodeIds: [],
|
||
viewport: DEFAULT_VIEWPORT,
|
||
tool: "select",
|
||
},
|
||
"video"
|
||
);
|
||
}
|
||
setIsProjectLoading(false);
|
||
}
|
||
|
||
void loadProject();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [canvasId]);
|
||
|
||
const saveCanvas = useCallback(async () => {
|
||
setIsSaving(true);
|
||
const result = await canvasService.saveCanvasState(
|
||
canvasId,
|
||
{
|
||
nodes: serializeNodes(nodes) as any,
|
||
edges: serializeEdges(edges) as any,
|
||
selectedNodeIds: selectedNodeId ? [selectedNodeId] : [],
|
||
viewport,
|
||
tool: "select",
|
||
},
|
||
"video"
|
||
);
|
||
if (result.success) {
|
||
toast.success("已保存");
|
||
} else {
|
||
toast.error(result.error || "保存失败");
|
||
}
|
||
setIsSaving(false);
|
||
}, [canvasId, edges, nodes, selectedNodeId, viewport]);
|
||
|
||
const commitTitle = useCallback(async () => {
|
||
const nextTitle = draftTitle.trim() || "未命名";
|
||
setProjectTitle(nextTitle);
|
||
setDraftTitle(nextTitle);
|
||
setIsTitleEditing(false);
|
||
await canvasService.updateProject(canvasId, { title: nextTitle }, "video");
|
||
}, [canvasId, draftTitle]);
|
||
|
||
const addNodes = useCallback((nextNodes: CloneNode[], nextEdges: CloneEdge[]) => {
|
||
setNodes((current) => [...current, ...nextNodes]);
|
||
setEdges((current) => [...current, ...nextEdges]);
|
||
const last = nextNodes[nextNodes.length - 1];
|
||
if (last) setSelectedNodeId(last.id);
|
||
}, []);
|
||
|
||
const addToolFlow = useCallback(
|
||
(tool: CapturedTool) => {
|
||
const type = getToolType(tool);
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
const origin = { x: 420 + nodes.length * 18, y: 330 + nodes.length * 10 };
|
||
const newNodes: CloneNode[] = [];
|
||
const newEdges: CloneEdge[] = [];
|
||
|
||
if (meta.inputType && type !== "text") {
|
||
const input = createInputNode(meta.inputType, origin.x, origin.y);
|
||
const model = createToolNode(tool, origin.x + 300, origin.y + 18);
|
||
newNodes.push(input, model);
|
||
newEdges.push({ id: makeId("edge"), source: input.id, target: model.id });
|
||
} else {
|
||
newNodes.push(createToolNode(tool, origin.x + 120, origin.y + 12));
|
||
}
|
||
|
||
addNodes(newNodes, newEdges);
|
||
setActivePanel(null);
|
||
toast.success(`已添加 ${getToolLabel(tool)}`);
|
||
},
|
||
[addNodes, nodes.length]
|
||
);
|
||
|
||
const addWorkflowPreset = useCallback(
|
||
(workflow: WorkflowPreset, index = 0) => {
|
||
const node = createWorkflowNode(workflow, 440 + index * 24, 350 + index * 18);
|
||
addNodes([node], []);
|
||
setActivePanel(null);
|
||
toast.success("已添加工作流");
|
||
},
|
||
[addNodes]
|
||
);
|
||
|
||
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);
|
||
setActivePanel(null);
|
||
setActiveStarterScene(starterScene);
|
||
return;
|
||
}
|
||
|
||
setActiveStarterScene(null);
|
||
const baseX = 380;
|
||
const baseY = 325;
|
||
const newNodes = starter.steps.map((title, index) => {
|
||
if (/上传图片|首帧图/.test(title)) return createInputNode("image", baseX + index * 270, baseY);
|
||
if (/上传音频|音频/.test(title) && index === 0) return createInputNode("audio", baseX + index * 270, baseY);
|
||
if (/脚本|描述/.test(title)) return createSimpleNode(title, "text", baseX + index * 270, baseY + 18);
|
||
if (/预览/.test(title)) return createSimpleNode(title, "preview", baseX + index * 270, baseY + 18);
|
||
if (/保存/.test(title)) return createSimpleNode(title, "save", baseX + index * 270, baseY + 18);
|
||
const matchedTool = capturedTools.find((tool) => getToolLabel(tool).includes(title) || title.includes(getToolLabel(tool)));
|
||
return matchedTool
|
||
? createToolNode(matchedTool, baseX + index * 270, baseY + 18)
|
||
: createSimpleNode(title, "workflow", baseX + index * 270, baseY + 18);
|
||
});
|
||
const newEdges = newNodes.slice(1).map((node, index) => ({
|
||
id: makeId("edge"),
|
||
source: newNodes[index].id,
|
||
target: node.id,
|
||
}));
|
||
addNodes(newNodes, newEdges);
|
||
},
|
||
[addNodes]
|
||
);
|
||
|
||
const updateNodeDraft = useCallback((key: string, value: unknown) => {
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === selectedNodeId
|
||
? { ...node, draftValues: { ...(node.draftValues || {}), [key]: value } }
|
||
: node
|
||
)
|
||
);
|
||
}, [selectedNodeId]);
|
||
|
||
const startNodeDrag = useCallback((event: ReactPointerEvent, node: CloneNode) => {
|
||
event.stopPropagation();
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
setSelectedNodeId(node.id);
|
||
dragRef.current = {
|
||
kind: "node",
|
||
id: node.id,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
nodeX: node.x,
|
||
nodeY: node.y,
|
||
};
|
||
}, []);
|
||
|
||
const startCanvasDrag = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
|
||
setSelectedNodeId(null);
|
||
dragRef.current = {
|
||
kind: "canvas",
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
viewportX: viewport.x,
|
||
viewportY: viewport.y,
|
||
};
|
||
}, [viewport.x, viewport.y]);
|
||
|
||
const onPointerMove = useCallback(
|
||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||
const drag = dragRef.current;
|
||
if (!drag) return;
|
||
if (drag.kind === "node") {
|
||
const dx = (event.clientX - drag.startX) / viewport.zoom;
|
||
const dy = (event.clientY - drag.startY) / viewport.zoom;
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === drag.id
|
||
? { ...node, x: Math.round(drag.nodeX + dx), y: Math.round(drag.nodeY + dy) }
|
||
: node
|
||
)
|
||
);
|
||
return;
|
||
}
|
||
setViewport((current) => ({
|
||
...current,
|
||
x: Math.round(drag.viewportX + event.clientX - drag.startX),
|
||
y: Math.round(drag.viewportY + event.clientY - drag.startY),
|
||
}));
|
||
},
|
||
[viewport.zoom]
|
||
);
|
||
|
||
const onPointerUp = useCallback(() => {
|
||
dragRef.current = null;
|
||
}, []);
|
||
|
||
const zoomBy = useCallback((delta: number) => {
|
||
setViewport((current) => ({
|
||
...current,
|
||
zoom: Math.min(2.4, Math.max(0.3, Number((current.zoom + delta).toFixed(2)))),
|
||
}));
|
||
}, []);
|
||
|
||
const copyShareLink = useCallback(async () => {
|
||
const url =
|
||
typeof window === "undefined"
|
||
? `/canvas/${canvasId}?mode=video&workspaceType=video`
|
||
: window.location.href;
|
||
await navigator.clipboard?.writeText(url).catch(() => undefined);
|
||
setShareEnabled(true);
|
||
toast.success("分享链接已复制");
|
||
}, [canvasId]);
|
||
|
||
const isBusy = isLoading || isProjectLoading;
|
||
|
||
return (
|
||
<div
|
||
className="relative h-dvh w-full overflow-hidden bg-[#151515] text-white"
|
||
onPointerDown={startCanvasDrag}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
ref={canvasRef}
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.18)_1px,transparent_1px)] bg-[length:16px_16px] bg-[position:2px_2px] opacity-[0.28]" />
|
||
|
||
<div
|
||
className="absolute left-0 top-0 h-[4000px] w-[4000px] origin-top-left"
|
||
style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})` }}
|
||
>
|
||
<CloneEdges edges={edges} nodes={nodes} />
|
||
{nodes.map((node) => (
|
||
<CloneCanvasNode
|
||
key={node.id}
|
||
node={node}
|
||
selected={node.id === selectedNodeId}
|
||
onPointerDown={(event) => startNodeDrag(event, node)}
|
||
/>
|
||
))}
|
||
</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 ? (
|
||
<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)]">
|
||
<div className="mb-6 flex items-center justify-center gap-2 text-[13px] leading-none text-white/58">
|
||
<PointerBadge />
|
||
<span className="font-bold text-white/60">双击画布</span>
|
||
<span>自由生成节点</span>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{STARTER_CARDS.map((card) => {
|
||
const cover = card.coverUrl || capturedWorkflows[0]?.cover || "";
|
||
return (
|
||
<button
|
||
className="group relative flex h-[56px] min-w-12 cursor-pointer items-center gap-2 overflow-hidden rounded-[8px] border border-neutral-800 px-4 py-1 text-left shadow-[0_4px_10px_rgba(0,0,0,0.04)] transition"
|
||
key={card.title}
|
||
onClick={() => createStarter(card)}
|
||
type="button"
|
||
>
|
||
{cover ? (
|
||
<img
|
||
alt=""
|
||
className="absolute left-0 top-0 h-full w-full max-w-none object-cover transition-opacity duration-300 ease-out group-hover:opacity-100"
|
||
src={cover}
|
||
/>
|
||
) : null}
|
||
<span className="absolute inset-0 rounded-[8px] opacity-0 backdrop-blur-[6px] transition-opacity duration-300 ease-out group-hover:opacity-100 bg-white/12" />
|
||
<span className="relative z-10 flex items-center gap-2 transition-opacity duration-200 ease-out group-hover:opacity-0">
|
||
{card.icon === "music" ? <MusicStarterIcon /> : <span />}
|
||
<span className="text-xs leading-none text-white">{card.title}</span>
|
||
</span>
|
||
<span className="pointer-events-none absolute left-1/2 top-1/2 z-20 flex h-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center gap-1 rounded-full bg-black/85 px-3 py-2 text-[12px] text-white opacity-0 transition-all duration-300 ease-out group-hover:opacity-100">
|
||
去生成
|
||
<ArrowGlyph />
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{!activeStarterScene ? (
|
||
<>
|
||
<TopBar
|
||
draftTitle={draftTitle}
|
||
isSaving={isSaving}
|
||
isTitleEditing={isTitleEditing}
|
||
onAccount={() => setActivePanel("account")}
|
||
onBell={() => setActivePanel("notifications")}
|
||
onCommitTitle={commitTitle}
|
||
onDraftTitleChange={setDraftTitle}
|
||
onMember={() => setActivePanel("member")}
|
||
onSave={saveCanvas}
|
||
onShare={() => setActivePanel("share")}
|
||
onSkills={() => setActivePanel("skills")}
|
||
onStartTitleEdit={() => setIsTitleEditing(true)}
|
||
projectTitle={projectTitle}
|
||
routerBack={() => router.push("/")}
|
||
/>
|
||
|
||
<LeftToolbar activePanel={activePanel} onOpen={(panel) => setActivePanel((current) => current === panel ? null : panel)} />
|
||
</>
|
||
) : null}
|
||
|
||
<ZoomDock
|
||
compact={Boolean(activeStarterScene)}
|
||
zoom={viewport.zoom}
|
||
onCenter={() => setViewport(DEFAULT_VIEWPORT)}
|
||
onZoomIn={() => zoomBy(0.1)}
|
||
onZoomOut={() => zoomBy(-0.1)}
|
||
/>
|
||
|
||
{activePanel === "add" ? (
|
||
<AddModulePanel
|
||
filteredTools={filteredTools}
|
||
search={toolSearch}
|
||
tab={toolTab}
|
||
onAddTool={addToolFlow}
|
||
onClose={() => setActivePanel(null)}
|
||
onSearchChange={setToolSearch}
|
||
onTabChange={setToolTab}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "workflow" ? (
|
||
<WorkflowPanel
|
||
workflows={capturedWorkflows}
|
||
onAdd={addWorkflowPreset}
|
||
onClose={() => setActivePanel(null)}
|
||
/>
|
||
) : null}
|
||
|
||
{selectedNode ? (
|
||
<InspectorPanel
|
||
node={selectedNode}
|
||
onChangeProperty={updateNodeDraft}
|
||
onClose={() => setSelectedNodeId(null)}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "assets" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="素材库">
|
||
<AssetLibraryPanel />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "history" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="历史记录">
|
||
<div className="space-y-2">
|
||
{nodes.length === 0 ? (
|
||
<EmptyState icon={Clock3} title="暂无历史" description="节点执行、草稿版本和回滚记录待接入。" />
|
||
) : (
|
||
nodes.slice(-10).reverse().map((node) => (
|
||
<button
|
||
className="flex w-full items-center justify-between rounded-[8px] border border-white/8 bg-white/5 px-3 py-2 text-left hover:bg-white/8"
|
||
key={node.id}
|
||
onClick={() => setSelectedNodeId(node.id)}
|
||
type="button"
|
||
>
|
||
<span>
|
||
<span className="block text-[13px] text-white/86">{node.title}</span>
|
||
<span className="text-[11px] text-white/38">{node.subtitle || node.kind}</span>
|
||
</span>
|
||
<ChevronDown className="-rotate-90 h-4 w-4 text-white/38" />
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "help" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="快捷键">
|
||
<div className="grid gap-2 text-[13px] text-white/70">
|
||
{[
|
||
["V", "选择"],
|
||
["Space", "拖动画布"],
|
||
["双击画布", "自由生成节点"],
|
||
["连接点拖拽", "连接模块"],
|
||
["⌘ / Ctrl + S", "保存画布"],
|
||
].map(([key, value]) => (
|
||
<div className="flex items-center justify-between rounded-[8px] bg-white/5 px-3 py-2" key={key}>
|
||
<span>{value}</span>
|
||
<span className="rounded-[6px] bg-white/10 px-2 py-1 text-white/58">{key}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "support" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="支持中心">
|
||
<SupportPanel />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "skills" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="LibTV Skills">
|
||
<SkillsAccessPanel />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "share" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="分享">
|
||
<div className="space-y-4">
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-4">
|
||
<div className="font-medium">分享链接</div>
|
||
<div className="mt-1 text-[12px] text-white/42">复制当前画布链接,权限控制待接入。</div>
|
||
<button
|
||
className="mt-4 h-10 w-full rounded-[8px] bg-white font-semibold text-black hover:bg-white/90"
|
||
onClick={() => void copyShareLink()}
|
||
type="button"
|
||
>
|
||
<Copy className="mr-2 inline h-4 w-4" />
|
||
复制链接
|
||
</button>
|
||
</div>
|
||
<ToggleRow checked={shareEnabled} label="启用分享" onChange={() => setShareEnabled((value) => !value)} />
|
||
<button
|
||
className="h-10 w-full rounded-[8px] bg-[#2f7df6] font-semibold text-white hover:bg-[#3f8bff]"
|
||
onClick={() => setActivePanel("publish")}
|
||
type="button"
|
||
>
|
||
发布作品
|
||
</button>
|
||
</div>
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "publish" ? (
|
||
<PublishModal
|
||
projectTitle={projectTitle}
|
||
publishPublic={publishPublic}
|
||
onClose={() => setActivePanel(null)}
|
||
onTogglePublic={() => setPublishPublic((value) => !value)}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "member" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="会员中心">
|
||
<MemberCenterPanel />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "notifications" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="通知">
|
||
<NotificationsPanel />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{activePanel === "account" ? (
|
||
<LibModal onClose={() => setActivePanel(null)} title="账号">
|
||
<AccountPanel />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{isBusy ? (
|
||
<div className="absolute inset-0 z-[120] flex items-center justify-center bg-[#151515]">
|
||
<div className="flex flex-col items-center gap-3 text-white/72">
|
||
<Loader2 className="h-7 w-7 animate-spin" />
|
||
<span className="text-sm">项目加载中...</span>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TopBar({
|
||
draftTitle,
|
||
isSaving,
|
||
isTitleEditing,
|
||
onAccount,
|
||
onBell,
|
||
onCommitTitle,
|
||
onDraftTitleChange,
|
||
onMember,
|
||
onSave,
|
||
onShare,
|
||
onSkills,
|
||
onStartTitleEdit,
|
||
projectTitle,
|
||
routerBack,
|
||
}: {
|
||
draftTitle: string;
|
||
isSaving: boolean;
|
||
isTitleEditing: boolean;
|
||
onAccount: () => void;
|
||
onBell: () => void;
|
||
onCommitTitle: () => void;
|
||
onDraftTitleChange: (value: string) => void;
|
||
onMember: () => void;
|
||
onSave: () => void;
|
||
onShare: () => void;
|
||
onSkills: () => void;
|
||
onStartTitleEdit: () => void;
|
||
projectTitle: string;
|
||
routerBack: () => void;
|
||
}) {
|
||
return (
|
||
<header className="pointer-events-none absolute left-0 right-0 top-4 z-40 flex h-12 items-center justify-between pl-2 pr-4">
|
||
<div className="pointer-events-auto flex items-center gap-3">
|
||
<button
|
||
className="flex h-10 cursor-pointer items-center justify-center rounded-xl px-2 text-white transition-colors hover:bg-white/10"
|
||
onClick={routerBack}
|
||
type="button"
|
||
>
|
||
<LibTVLogo />
|
||
</button>
|
||
<div className="h-4 w-px bg-white/14" />
|
||
{isTitleEditing ? (
|
||
<input
|
||
autoFocus
|
||
className="h-8 min-w-[60px] max-w-[160px] rounded-[8px] border border-white/14 bg-transparent px-2 text-sm font-medium text-white outline-none"
|
||
onBlur={onCommitTitle}
|
||
onChange={(event) => onDraftTitleChange(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") onCommitTitle();
|
||
}}
|
||
value={draftTitle}
|
||
/>
|
||
) : (
|
||
<button
|
||
className="max-w-[160px] truncate text-sm font-medium text-white/78"
|
||
onClick={onStartTitleEdit}
|
||
type="button"
|
||
>
|
||
{projectTitle || "未命名"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="pointer-events-auto flex items-center gap-2">
|
||
<TopButton onClick={onSkills}>LibTV Skills</TopButton>
|
||
<TopButton onClick={onSave} title="保存">
|
||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||
</TopButton>
|
||
<TopButton onClick={onShare} title="分享">
|
||
<Share2 className="h-4 w-4" />
|
||
</TopButton>
|
||
<TopButton className="relative" onClick={onBell} title="通知">
|
||
<span className="absolute right-px top-px h-[5px] w-[5px] rounded-full bg-[#f53f3f]" />
|
||
<Bell className="h-4 w-4" />
|
||
</TopButton>
|
||
<TopButton className="gap-1 px-3 text-[#05dff6]" onClick={onMember}>
|
||
<StoreGlyph />
|
||
会员超市
|
||
</TopButton>
|
||
<MemberDealPill onAccount={onAccount} onMember={onMember} />
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function TopButton({
|
||
children,
|
||
className,
|
||
onClick,
|
||
title,
|
||
}: {
|
||
children: React.ReactNode;
|
||
className?: string;
|
||
onClick: () => void;
|
||
title?: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex h-10 min-w-10 cursor-pointer items-center justify-center rounded-xl border border-white/8 bg-[#202020] px-3 text-[13px] font-normal text-white/72 shadow-[0_12px_30px_rgba(0,0,0,0.28)] transition hover:bg-[#2a2a2a] hover:text-white",
|
||
className
|
||
)}
|
||
onClick={onClick}
|
||
title={title}
|
||
type="button"
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function LeftToolbar({
|
||
activePanel,
|
||
onOpen,
|
||
}: {
|
||
activePanel: ActivePanel;
|
||
onOpen: (panel: Exclude<ActivePanel, null>) => void;
|
||
}) {
|
||
return (
|
||
<aside
|
||
className="-translate-y-1/2 absolute left-4 top-1/2 z-40 flex flex-col items-center gap-2 rounded-xl border border-white/9 bg-[#202020]/95 p-2 shadow-[0_22px_60px_rgba(0,0,0,0.45)] backdrop-blur-[8px]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
{TOOLBAR_ITEMS.map((item, index) => {
|
||
const Icon = item.icon;
|
||
const isActive = activePanel === item.id || (!activePanel && item.id === "add");
|
||
return (
|
||
<div className="contents" key={item.id}>
|
||
<button
|
||
className={cn(
|
||
"group flex h-9 w-9 cursor-pointer items-center justify-center rounded-[12px] text-white/72 transition-colors hover:bg-white/10 hover:text-white",
|
||
isActive && "bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
onClick={() => onOpen(item.id)}
|
||
title={item.label}
|
||
type="button"
|
||
>
|
||
{item.id === "assets" ? (
|
||
<AssetGlyph />
|
||
) : item.id === "workflow" ? (
|
||
<WorkflowGlyph />
|
||
) : (
|
||
<Icon className={cn("h-[18px] w-[18px]", item.id === "add" && "transition-transform duration-200 group-hover:rotate-45")} />
|
||
)}
|
||
</button>
|
||
{index === 3 ? <div className="my-0 h-px w-5 shrink-0 bg-white/10" /> : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function ZoomDock({
|
||
compact = false,
|
||
onCenter,
|
||
onZoomIn,
|
||
onZoomOut,
|
||
zoom,
|
||
}: {
|
||
compact?: boolean;
|
||
onCenter: () => void;
|
||
onZoomIn: () => void;
|
||
onZoomOut: () => void;
|
||
zoom: number;
|
||
}) {
|
||
if (compact) {
|
||
return (
|
||
<div
|
||
className="absolute bottom-4 left-0 z-40 flex items-center gap-1 rounded-r-xl border border-l-0 border-white/9 bg-[#202020]/95 p-1.5 text-white/70 shadow-[0_18px_48px_rgba(0,0,0,0.42)] backdrop-blur-[8px]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<button className="flex h-7 w-10 items-center justify-center rounded-lg bg-white/10 transition-colors hover:bg-white/14" type="button">
|
||
<MagnetGlyph />
|
||
</button>
|
||
<button className="flex h-7 w-10 items-center justify-center rounded-lg text-[18px] leading-none transition-colors hover:bg-white/10" onClick={onZoomOut} type="button">
|
||
-
|
||
</button>
|
||
<button className="h-7 min-w-[72px] rounded-lg px-1 text-[13px] leading-normal transition-colors hover:bg-white/10" onClick={onCenter} type="button">
|
||
77%
|
||
</button>
|
||
<button className="flex h-7 w-10 items-center justify-center rounded-lg text-[18px] leading-none transition-colors hover:bg-white/10" onClick={onZoomIn} type="button">
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="absolute bottom-4 left-4 z-40 flex items-center gap-1 rounded-xl border border-white/9 bg-[#202020]/95 p-1.5 text-white/70 shadow-[0_18px_48px_rgba(0,0,0,0.42)] backdrop-blur-[8px]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<button className="flex h-7 w-7 items-center justify-center rounded-lg transition-colors hover:bg-white/10" type="button">
|
||
<GridTilesGlyph />
|
||
</button>
|
||
<button className="flex h-7 w-7 items-center justify-center rounded-lg transition-colors hover:bg-white/10" type="button">
|
||
<MapPinGlyph />
|
||
</button>
|
||
<button className="flex h-7 w-7 items-center justify-center rounded-lg bg-white/10 transition-colors hover:bg-white/14" type="button">
|
||
<MagnetGlyph />
|
||
</button>
|
||
<button className="flex h-7 w-7 items-center justify-center rounded-lg text-[18px] leading-none transition-colors hover:bg-white/10" onClick={onZoomOut} type="button">
|
||
-
|
||
</button>
|
||
<button className="h-7 min-w-[58px] rounded-lg px-1 text-[13px] leading-normal transition-colors hover:bg-white/10" onClick={onCenter} type="button">
|
||
{Math.round(zoom * 100)}%
|
||
</button>
|
||
<button className="flex h-7 w-7 items-center justify-center rounded-lg text-[18px] leading-none transition-colors hover:bg-white/10" onClick={onZoomIn} type="button">
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LibTVLogo() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-6 text-white" fill="currentColor" height="17" viewBox="0 0 76.234 16.79" width="77">
|
||
<path d="M16.576 16.616H0l.833-4.418H17.65z" />
|
||
<path d="m0 16.616 2.314-12.27h4.448l-2.316 12.27zM8.27 0h16.936l-.832 4.416H7.544z" />
|
||
<path d="m25.206 0-2.314 12.27-4.512.002L20.76 0zM30.857 14.816 33.09 2.217h2.7l-1.82 10.276h4.968l-.415 2.321h-7.666zM41.025 6.328h2.556l-1.639 8.488h-2.537l1.619-8.488zm.235-2.51c0-.882.701-1.547 1.566-1.547.81 0 1.367.54 1.367 1.315 0 .882-.702 1.547-1.566 1.547-.81 0-1.367-.54-1.367-1.314M53.696 9.488c0 3.077-2.232 5.435-4.933 5.435-1.458 0-2.268-.559-2.735-1.296l-.45 1.189h-2.232L45.56 2.217h2.538l-.738 4.265c.63-.63 1.44-1.025 2.555-1.025 2.214 0 3.78 1.71 3.78 4.03m-2.574.198c0-1.422-.756-2.16-1.87-2.16-1.657 0-2.7 1.547-2.7 3.168 0 1.421.774 2.16 1.89 2.16 1.638 0 2.682-1.548 2.682-3.168zM53.929 2.217h9.934l-.395 2.321H59.85L58.03 14.814h-2.699l1.819-10.276h-3.618zM72.934 2.217h2.879l-6.497 12.599h-3.222L64.042 2.217h2.772l1.368 9.322z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function PointerBadge() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[22px] w-[22px] shrink-0" fill="none" viewBox="0 0 22 22">
|
||
<circle cx="8" cy="7.904" fill="#171717" r="7.3" />
|
||
<circle cx="8" cy="7.904" fill="#525252" r="4.103" />
|
||
<path
|
||
d="M6.86 8.082c-.299-.824.499-1.621 1.322-1.323l11.394 4.12a.478.478 0 0 1 .026.89 14.72 14.72 0 0 0-7.733 7.732.478.478 0 0 1-.889-.025z"
|
||
fill="#f7f7f7"
|
||
stroke="#f7f7f7"
|
||
strokeLinejoin="round"
|
||
strokeWidth="0.945"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function ArrowGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-3 w-3 text-white" fill="currentColor" viewBox="0 0 20.1132 17.9127">
|
||
<path d="M10.957 0.117086C11.1132 -0.0390761 11.3662 -0.0389815 11.5225 0.117086L19.9375 8.53213C20.1718 8.76644 20.1718 9.14644 19.9375 9.38076L11.5225 17.7958C11.3663 17.9516 11.1131 17.9518 10.957 17.7958L10.249 17.0878C10.0931 16.9317 10.0932 16.6786 10.249 16.5224L16.915 9.85634H0.399414C0.178762 9.85616 0.000169635 9.67759 0 9.45693V8.45595C0 8.23515 0.178657 8.05575 0.399414 8.05556H16.915L10.249 1.39052C10.0931 1.23442 10.0932 0.981285 10.249 0.825094L10.957 0.117086Z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function MusicStarterIcon() {
|
||
return (
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] bg-white text-black">
|
||
<Music2 className="h-5 w-5" />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function StoreGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-4 w-4 shrink-0" fill="currentColor" viewBox="0 0 16 16">
|
||
<path
|
||
clipRule="evenodd"
|
||
d="M11.5703 7.32031C11.7041 7.40647 11.8427 7.49125 11.9863 7.56543C12.4351 7.79714 13.0016 7.99984 13.5938 8C13.9834 7.99995 14.5104 7.94128 15.0498 7.83203V10.25C15.0498 11.2914 15.0517 12.1405 14.9619 12.8086C14.8695 13.4961 14.6692 14.0925 14.1934 14.5684C13.7175 15.0442 13.1211 15.2445 12.4336 15.3369C11.7655 15.4267 10.9164 15.4248 9.875 15.4248H6.125C5.08356 15.4248 4.23451 15.4267 3.56641 15.3369C2.87886 15.2445 2.28246 15.0442 1.80664 14.5684C1.33083 14.0925 1.13053 13.4961 1.03809 12.8086C0.948294 12.1405 0.950198 11.2914 0.950198 10.25V7.83594C1.49346 7.94052 2.02537 7.99896 2.40625 7.99902C2.99823 7.99894 3.56495 7.79594 4.01367 7.56445C4.15699 7.49047 4.29517 7.40621 4.42871 7.32031C4.85021 7.66646 5.42619 7.99885 6.16504 7.99902C6.77646 7.99893 7.3045 7.77311 7.70996 7.52246C7.81016 7.46049 7.90621 7.39372 7.99805 7.32617C8.09066 7.39438 8.18892 7.45994 8.29004 7.52246C8.69562 7.77313 9.22373 7.99883 9.83496 7.99902C10.5728 7.99886 11.1488 7.66619 11.5703 7.32031ZM8 9.5C6.89544 9.5 6 10.3954 6 11.5V13.5C6 14.0523 6.44772 14.5 7 14.5H9C9.55229 14.5 10 14.0523 10 13.5V11.5C10 10.3954 9.10457 9.5 8 9.5ZM9.83496 6.99902L9.69922 6.99316C9.68917 6.99225 9.67896 6.98944 9.66895 6.98828C9.72373 6.9944 9.77923 6.99901 9.83496 6.99902Z"
|
||
fillRule="evenodd"
|
||
/>
|
||
<path d="M8.00053 1.00014C11.0011 1.00014 12.6726 0.985326 13.3162 1.28875C13.9598 1.59225 14.3832 2.15654 14.9903 2.92752L15.2752 3.28903L15.2838 3.30002L15.4892 3.59462C15.6898 3.90524 15.8674 4.26195 15.9504 4.63421C16.065 5.14874 16.0048 5.74519 15.515 6.23304C15.0392 6.7069 14.3509 7 13.5936 7C12.8445 6.9999 11.9776 6.47094 11.5 6C11.0434 6.46652 10.567 6.999 9.83479 6.999C9.10776 6.99887 8.45603 6.46088 8 6C7.5439 6.4606 6.89205 6.999 6.16519 6.999C5.4321 6.99897 4.95655 6.46754 4.5 6C4.02234 6.47025 3.15497 6.999 2.40635 6.999C1.6491 6.99895 0.960752 6.70587 0.484999 6.23204C-0.00468519 5.74422 -0.0650214 5.1477 0.04955 4.63321C0.160132 4.1368 0.438679 3.66782 0.716163 3.29902L0.724765 3.28804L1.00969 2.92653C1.61692 2.1553 2.041 1.59122 2.68482 1.28775C3.32863 0.984389 5 1.00014 8.00053 1.00014Z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function DiamondGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 16 16">
|
||
<path
|
||
clipRule="evenodd"
|
||
d="M12.19 1.333c.696 0 1.33.406 1.618 1.04L15.506 6.1a1.78 1.78 0 0 1-.319 1.952l-5.889 6.294c-.702.75-1.894.75-2.596 0L.813 8.052A1.78 1.78 0 0 1 .493 6.1l1.7-3.727c.289-.633.92-1.04 1.617-1.04zM6.467 5.971l-1.8-.005 3.12 3.12c.099.1.26.1.358 0l3.114-3.112c.007-.007.01-.01 0-.01h-1.8L8.075 7.349a.16.16 0 0 1-.226-.001L6.46 5.96c.002.002.01.01.007.01"
|
||
fill="#FFC65D"
|
||
fillRule="evenodd"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function LightningGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[10px] w-2 shrink-0 text-[#FFDBA4]" fill="none" viewBox="0 0 8 10">
|
||
<path
|
||
d="M7.306 4.36H5.79V.76c0-.84-.447-1.01-.992-.38l-.393.455-3.323 3.85c-.456.525-.265.955.422.955h1.517v3.6c0 .84.447 1.01.992.38l.392-.455 3.324-3.85c.456-.525.265-.955-.423-.955"
|
||
fill="currentColor"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function MemberDealPill({ onAccount, onMember }: { onAccount: () => void; onMember: () => void }) {
|
||
return (
|
||
<div className="flex h-10 items-center gap-2 rounded-xl border border-white/8 bg-[#202020] px-3 py-[6px] text-[12px] text-white/76 shadow-[0_12px_30px_rgba(0,0,0,0.28)]">
|
||
<button className="relative flex cursor-pointer items-center gap-1 rounded-[8px] px-0.5 hover:text-white" onClick={onMember} type="button">
|
||
<span className="absolute -top-5 left-0 z-10 flex h-4 whitespace-nowrap rounded-md rounded-bl-none bg-[#FAD6A4] px-2 py-0.5 text-center text-[10px] leading-[12px] text-[#0D0D0D]">
|
||
限时 39 折
|
||
</span>
|
||
<DiamondGlyph />
|
||
<span>
|
||
会员特惠<span className="font-[800]">39折</span>
|
||
</span>
|
||
</button>
|
||
<button className="flex cursor-pointer items-center gap-1 rounded-[8px] px-0.5 hover:text-white" onClick={onMember} type="button">
|
||
<LightningGlyph />
|
||
<span>100</span>
|
||
</button>
|
||
<button className="rounded-full" onClick={onAccount} type="button">
|
||
<img alt="avatar" className="h-7 w-7 rounded-full object-cover" src={TARGET_AVATAR} />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AssetGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[18px] w-[18px]" fill="none" viewBox="0 0 18 18">
|
||
<path d="M9 1.65 15.15 5.1 9 8.55 2.85 5.1 9 1.65Z" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
|
||
<path d="M2.85 8.05 9 11.5l6.15-3.45M2.85 11 9 14.45 15.15 11" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.7" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function WorkflowGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[18px] w-[18px]" fill="none" viewBox="0 0 18 18">
|
||
<path d="M5.25 4.5a1.9 1.9 0 1 1-3.8 0 1.9 1.9 0 0 1 3.8 0ZM16.55 4.5a1.9 1.9 0 1 1-3.8 0 1.9 1.9 0 0 1 3.8 0ZM5.25 13.5a1.9 1.9 0 1 1-3.8 0 1.9 1.9 0 0 1 3.8 0Z" stroke="currentColor" strokeWidth="1.7" />
|
||
<path d="M5.25 4.5h4.4c1.3 0 2.35 1.05 2.35 2.35v0c0 1.3-1.05 2.35-2.35 2.35H8.3c-1.3 0-2.35 1.05-2.35 2.35v1.95" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function GridTilesGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[14px] w-[14px]" fill="currentColor" viewBox="0 0 16 16">
|
||
<path d="M5.13379 8.33887C6.00346 8.33899 6.70886 9.04342 6.70898 9.91309V11.4883C6.70887 12.358 6.00347 13.0634 5.13379 13.0635H2.50879C1.6391 13.0634 0.933703 12.358 0.933594 11.4883V9.91309C0.933717 9.04341 1.63911 8.33898 2.50879 8.33887H5.13379ZM11.4922 6.23828C12.3619 6.23839 13.0674 6.94369 13.0674 7.81348V11.4883C13.0673 12.358 12.3619 13.0634 11.4922 13.0635H8.86719C7.99746 13.0634 7.2921 12.358 7.29199 11.4883V7.81348C7.29199 6.94367 7.99739 6.23834 8.86719 6.23828H11.4922ZM2.45508 9.3916C2.19053 9.41859 1.98351 9.64144 1.9834 9.91309V11.4883C1.9835 11.7599 2.19051 11.9838 2.45508 12.0107L2.50879 12.0137H5.13379L5.1875 12.0107C5.45212 11.9838 5.6581 11.76 5.6582 11.4883V9.91309C5.65809 9.64141 5.4521 9.41854 5.1875 9.3916L5.13379 9.38867H2.50879L2.45508 9.3916ZM8.86719 7.28809C8.57729 7.28815 8.34277 7.52356 8.34277 7.81348V11.4883L8.34473 11.542C8.36987 11.789 8.56642 11.9857 8.81348 12.0107L8.86719 12.0137H11.4922L11.5459 12.0107C11.7929 11.9856 11.9895 11.789 12.0146 11.542L12.0176 11.4883V7.81348C12.0176 7.52359 11.782 7.2882 11.4922 7.28809H8.86719ZM5.13379 0.929688C6.00347 0.929811 6.70887 1.6352 6.70898 2.50488V6.17969C6.70892 7.04941 6.00349 7.75476 5.13379 7.75488H2.50879C1.63907 7.75477 0.933655 7.04942 0.933594 6.17969V2.50488C0.933704 1.6352 1.6391 0.929797 2.50879 0.929688H5.13379ZM2.45508 1.98242C2.19052 2.0094 1.9835 2.23322 1.9834 2.50488V6.17969L1.98633 6.2334C2.01141 6.48042 2.20808 6.67697 2.45508 6.70215L2.50879 6.70508H5.13379L5.1875 6.70215C5.43455 6.67702 5.63116 6.48045 5.65625 6.2334L5.6582 6.17969V2.50488C5.6581 2.23318 5.45212 2.00935 5.1875 1.98242L5.13379 1.97949H2.50879L2.45508 1.98242ZM11.4922 0.929688C12.3619 0.929797 13.0673 1.63519 13.0674 2.50488V4.08008C13.0671 4.94966 12.3618 5.65419 11.4922 5.6543H8.86719C7.99754 5.65424 7.29223 4.94968 7.29199 4.08008V2.50488C7.2921 1.63517 7.99746 0.929749 8.86719 0.929688H11.4922ZM8.86719 1.97949C8.57736 1.97955 8.34288 2.21506 8.34277 2.50488V4.08008L8.34473 4.13379C8.37172 4.39837 8.59549 4.60443 8.86719 4.60449H11.4922C11.7638 4.60439 11.9877 4.39835 12.0146 4.13379L12.0176 4.08008V2.50488C12.0175 2.21509 11.782 1.9796 11.4922 1.97949H8.86719Z" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function MapPinGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[14px] w-[14px]" fill="none" viewBox="0 0 22 22">
|
||
<path d="M10.9 1C7.59 1 4.9 3.67 4.9 6.96c0 4.35 5.15 8.65 5.37 8.83.36.3.89.3 1.25 0 .22-.18 5.37-4.48 5.37-8.83C16.9 3.67 14.21 1 10.9 1Z" stroke="currentColor" strokeWidth="1.8" />
|
||
<path d="M10.9 9.25a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4Z" stroke="currentColor" strokeWidth="1.8" />
|
||
<path d="M6.9 13h-2.8a1.1 1.1 0 0 0-1.04.75L1.4 18.75A1.1 1.1 0 0 0 2.45 20.2h16.9a1.1 1.1 0 0 0 1.05-1.45l-1.66-5a1.1 1.1 0 0 0-1.04-.75h-2.8" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function MagnetGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[14px] w-[14px]" fill="none" viewBox="0 0 24 24">
|
||
<path
|
||
d="m12 15 4 4M2.352 10.648a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l6.029-6.029a1 1 0 1 1 3 3l-6.029 6.029a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l6.365-6.367A1 1 0 0 0 8.716 4.282zM5 8l4 4"
|
||
stroke="currentColor"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth="2"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function StoryScriptScene() {
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||
<PresetFloatingToolbar
|
||
items={[
|
||
["拆分场景", "按镜号拆分脚本接口待接入。"],
|
||
["生成分镜", "分镜生成接口待接入。"],
|
||
["表格视图", "脚本表格视图接口待接入。"],
|
||
["导出", "脚本导出接口待接入。"],
|
||
]}
|
||
/>
|
||
<div className="pointer-events-auto absolute left-1/2 top-[196px] flex -translate-x-1/2 items-start gap-[112px]">
|
||
<StoryTextPanel />
|
||
<SceneConnector className="mt-[170px]" />
|
||
<ScriptGeneratorPanel />
|
||
</div>
|
||
<PresetComposerPanel
|
||
credit="8"
|
||
prompt={STORY_SCRIPT_PROMPT}
|
||
submitMessage="故事脚本生成接口待接入。"
|
||
title="脚本生成器"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FirstFrameVideoScene() {
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||
<PresetFloatingToolbar
|
||
items={[
|
||
["高清", "首帧高清接口待接入。"],
|
||
["扩图", "首帧扩图接口待接入。"],
|
||
["替换背景", "背景替换接口待接入。"],
|
||
["下载", "图片下载接口待接入。"],
|
||
]}
|
||
/>
|
||
<div className="pointer-events-auto absolute left-1/2 top-[190px] flex -translate-x-1/2 items-start gap-[108px]">
|
||
<PresetMediaPanel imageUrl={FIRST_FRAME_IMAGE_URL} label="首帧" wide />
|
||
<SceneConnector className="mt-[178px]" />
|
||
<VideoResultPanel label="视频" />
|
||
</div>
|
||
<PresetComposerPanel
|
||
credit="28"
|
||
prompt="以当前图为首帧生成视频。"
|
||
submitMessage="首帧图生视频接口待接入。"
|
||
thumbnailUrl={FIRST_FRAME_IMAGE_URL}
|
||
title="首帧图生视频"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AudioToVideoScene() {
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||
<PresetFloatingToolbar
|
||
items={[
|
||
["节奏匹配", "音频节奏解析接口待接入。"],
|
||
["镜头语言", "镜头语言配置接口待接入。"],
|
||
["生成音效", "音效生成接口待接入。"],
|
||
["下载", "素材下载接口待接入。"],
|
||
]}
|
||
/>
|
||
<div className="pointer-events-auto absolute left-1/2 top-[178px] flex -translate-x-1/2 items-start gap-[98px]">
|
||
<div className="flex flex-col gap-5">
|
||
<AudioResourcePanel />
|
||
<PresetMediaPanel imageUrl={AUDIO_REFERENCE_IMAGE_URL} label="图片" small />
|
||
</div>
|
||
<SceneConnector className="mt-[212px]" />
|
||
<VideoResultPanel label="视频" />
|
||
</div>
|
||
<PresetComposerPanel
|
||
credit="32"
|
||
prompt={AUDIO_TO_VIDEO_PROMPT}
|
||
submitMessage="音频生视频接口待接入。"
|
||
thumbnailUrl={AUDIO_REFERENCE_IMAGE_URL}
|
||
title="音频生视频"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PresetFloatingToolbar({ items }: { items: Array<[string, string]> }) {
|
||
return (
|
||
<div className="pointer-events-auto absolute left-1/2 top-[94px] flex h-[58px] -translate-x-1/2 items-center gap-2 rounded-[16px] border border-white/8 bg-[#262626]/96 px-4 text-white/78 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
|
||
{items.map(([label, message]) => (
|
||
<button
|
||
className="flex h-10 items-center gap-2 rounded-[10px] px-3 text-[15px] hover:bg-white/8 hover:text-white"
|
||
key={label}
|
||
onClick={() => toast.info(message)}
|
||
type="button"
|
||
>
|
||
<span className="flex h-5 w-5 items-center justify-center rounded-[5px] border border-white/18 text-[11px] text-white/58">
|
||
{label.slice(0, 1)}
|
||
</span>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SceneConnector({ className }: { className?: string }) {
|
||
return (
|
||
<div className={cn("relative h-7 w-[108px]", className)}>
|
||
<div className="absolute left-0 top-1/2 h-[2px] w-full -translate-y-1/2 bg-gradient-to-r from-[#5aa8ff] via-[#7a86ff] to-[#b6bcff]" />
|
||
<button
|
||
className="absolute left-1/2 top-1/2 flex h-7 w-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/45 bg-[#222] text-[22px] leading-none text-white/72"
|
||
onClick={() => toast.info("连接素材节点接口待接入。")}
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StoryTextPanel() {
|
||
return (
|
||
<div className="w-[560px]">
|
||
<div className="mb-2 flex items-center gap-1 text-[20px] font-semibold text-white/50">
|
||
<FileText className="h-5 w-5" />
|
||
剧本
|
||
</div>
|
||
<div className="h-[390px] overflow-hidden rounded-[10px] border border-white/12 bg-[#f4f5f7] text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]">
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function ScriptGeneratorPanel() {
|
||
const rows = [
|
||
["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"],
|
||
["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"],
|
||
["03", "长安街市", "诗声落下,盛唐山河展开", "史诗音乐起"],
|
||
];
|
||
return (
|
||
<div className="w-[560px]">
|
||
<div className="mb-2 flex items-center gap-1 text-[20px] font-semibold text-white/50">
|
||
<GridTilesGlyph />
|
||
脚本生成器
|
||
</div>
|
||
<div className="h-[390px] overflow-hidden rounded-[10px] border border-white/12 bg-white text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]">
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function PresetMediaPanel({
|
||
imageUrl,
|
||
label,
|
||
small = false,
|
||
wide = false,
|
||
}: {
|
||
imageUrl: string;
|
||
label: string;
|
||
small?: boolean;
|
||
wide?: boolean;
|
||
}) {
|
||
return (
|
||
<div className={cn("relative", small ? "w-[430px]" : wide ? "w-[620px]" : "w-[560px]")}>
|
||
<div className="mb-2 flex items-center gap-1 text-[20px] font-semibold text-white/50">
|
||
<ImageIcon className="h-5 w-5" />
|
||
{label}
|
||
</div>
|
||
<div className={cn("relative overflow-hidden rounded-[10px] border border-white/12 bg-white shadow-[0_18px_60px_rgba(0,0,0,0.36)]", small ? "h-[220px]" : "h-[390px]")}>
|
||
<img alt={label} className="h-full w-full object-cover" src={imageUrl} />
|
||
<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(`${label}素材替换接口待接入。`)}
|
||
type="button"
|
||
>
|
||
↥
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function VideoResultPanel({ label }: { label: string }) {
|
||
return (
|
||
<div className="w-[560px]">
|
||
<div className="mb-2 flex items-center gap-1 text-[20px] font-semibold text-white/50">
|
||
<Video className="h-5 w-5" />
|
||
{label}
|
||
</div>
|
||
<div className="relative flex h-[390px] items-center justify-center overflow-hidden rounded-[10px] border border-white/12 bg-[#101010] shadow-[0_18px_60px_rgba(0,0,0,0.36)]">
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function AudioResourcePanel() {
|
||
return (
|
||
<div className="w-[430px]">
|
||
<div className="mb-2 flex items-center gap-1 text-[20px] font-semibold text-white/50">
|
||
<Music2 className="h-5 w-5" />
|
||
音频
|
||
</div>
|
||
<div className="rounded-[10px] border border-white/12 bg-[#f4f5f7] p-4 text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]">
|
||
<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>
|
||
<a className="rounded-[8px] px-2 py-1 text-[12px] text-[#4f5cff] hover:bg-black/5" href={AUDIO_SOURCE_URL} rel="noreferrer" target="_blank">
|
||
试听
|
||
</a>
|
||
</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>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PresetComposerPanel({
|
||
credit,
|
||
prompt,
|
||
submitMessage,
|
||
thumbnailUrl,
|
||
title,
|
||
}: {
|
||
credit: string;
|
||
prompt: string;
|
||
submitMessage: string;
|
||
thumbnailUrl?: string;
|
||
title: string;
|
||
}) {
|
||
return (
|
||
<div className="pointer-events-auto absolute bottom-[46px] left-1/2 h-[238px] w-[972px] -translate-x-1/2 rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||
<button className="absolute right-5 top-5 text-[26px] text-white/45" onClick={() => toast.info("展开提示词编辑器待接入。")} type="button">↗</button>
|
||
<div className="mb-5 flex items-center gap-3">
|
||
<span className="rounded-[10px] border border-white/10 bg-[#292929] px-3 py-2 text-[14px] text-white/62">{title}</span>
|
||
{thumbnailUrl ? (
|
||
<div className="h-[54px] w-[54px] overflow-hidden rounded-[10px]">
|
||
<img alt="" className="h-full w-full object-cover" src={thumbnailUrl} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="text-[20px] text-white/54">{prompt}</div>
|
||
<div className="absolute bottom-5 left-5 right-5 flex items-center gap-6 text-[17px] text-white/78">
|
||
<ComposerControl label="Lib Nano Pro" icon="◉" dropdown onClick={() => toast.info("模型选择接口待接入。")} />
|
||
<ComposerControl label="16:9" icon="▭" dropdown onClick={() => toast.info("比例选择接口待接入。")} />
|
||
<ComposerControl label="2K" dropdown onClick={() => toast.info("清晰度选择接口待接入。")} />
|
||
<ComposerControl label="摄像机" icon="▣" onClick={() => toast.info("镜头配置接口待接入。")} />
|
||
<ComposerControl label="1张" dropdown onClick={() => toast.info("生成数量接口待接入。")} />
|
||
<ComposerControl label={credit} icon="⚡" muted onClick={() => toast.info("积分估算接口待接入。")} />
|
||
<button
|
||
className="ml-auto flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white"
|
||
onClick={() => toast.info(submitMessage)}
|
||
type="button"
|
||
>
|
||
↑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CharacterThreeViewScene() {
|
||
const [toolbarMenu, setToolbarMenu] = useState<SceneToolbarMenu>(null);
|
||
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||
<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}
|
||
|
||
<div className="pointer-events-auto absolute left-1/2 top-[174px] flex -translate-x-1/2 items-start gap-[110px]">
|
||
<CharacterImagePanel imageUrl={CHARACTER_IMAGE_URL} label="角色图" wide />
|
||
<div className="relative mt-[214px] w-[4px]">
|
||
<div className="absolute right-[4px] top-0 h-[2px] w-[110px] bg-gradient-to-r from-transparent via-[#59a8ff] to-[#9bd6ff]" />
|
||
<button
|
||
className="absolute -left-[16px] -top-[14px] flex h-7 w-7 items-center justify-center rounded-full border border-white/45 bg-[#222] text-[22px] leading-none text-white/72"
|
||
onClick={() => toast.info("连接素材节点接口待接入。")}
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
<CharacterImagePanel imageUrl={CHARACTER_THREE_VIEW_URL} label="角色三视图" />
|
||
</div>
|
||
|
||
<PromptComposer />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SceneTool({
|
||
active,
|
||
badge,
|
||
dropdown,
|
||
kind,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
active?: boolean;
|
||
badge?: string;
|
||
dropdown?: boolean;
|
||
kind: "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop";
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn("flex h-10 items-center gap-2 rounded-[8px] px-1 text-white/82 hover:bg-white/8", active && "bg-white/10 text-white")}
|
||
onClick={onClick}
|
||
type="button"
|
||
>
|
||
<SceneToolIcon kind={kind} />
|
||
<span>{label}</span>
|
||
{badge ? (
|
||
<span className="rounded-[7px] bg-[#0e6371] px-2 py-0.5 text-[14px] font-bold tracking-wide text-[#42eaff]">
|
||
{badge}
|
||
</span>
|
||
) : null}
|
||
{dropdown ? <span className="text-[16px] text-white/58">⌄</span> : null}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function SceneToolbarPopover({
|
||
menu,
|
||
onClose,
|
||
}: {
|
||
menu: Exclude<SceneToolbarMenu, null>;
|
||
onClose: () => void;
|
||
}) {
|
||
const quickOptions: Record<Exclude<SceneToolbarMenu, "lighting" | null>, string[]> = {
|
||
crop: ["4 宫格切分", "9 宫格切分", "16 宫格切分", "25 宫格切分"],
|
||
grid: ["2 x 2", "3 x 3", "4 x 4", "自定义宫格"],
|
||
hd: ["2倍高清", "4倍高清"],
|
||
orbit: ["正侧背三视角", "八方向参考", "环绕镜头"],
|
||
panorama: ["全景", "半身", "近景"],
|
||
};
|
||
|
||
if (menu === "lighting") {
|
||
return (
|
||
<div className="pointer-events-auto absolute left-1/2 top-[172px] z-40 w-[420px] -translate-x-1/2 rounded-[14px] border border-white/10 bg-[#262626]/98 p-4 text-white shadow-[0_24px_80px_rgba(0,0,0,0.46)] backdrop-blur-[10px]">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<div className="font-semibold text-[15px] text-white/86">打光</div>
|
||
<button className="rounded-[8px] px-2 py-1 text-white/42 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">×</button>
|
||
</div>
|
||
<textarea
|
||
className="h-24 w-full resize-none rounded-[10px] border border-white/10 bg-[#1d1d1d] px-3 py-2 text-[13px] text-white/78 outline-none placeholder:text-white/34"
|
||
placeholder="简单描述你想实现的打光效果,或者情绪风格"
|
||
/>
|
||
<button
|
||
className="mt-3 h-10 w-full rounded-[10px] bg-white font-semibold text-black hover:bg-white/90"
|
||
onClick={() => toast.info("打光生成接口待接入。")}
|
||
type="button"
|
||
>
|
||
应用打光
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="pointer-events-auto absolute left-1/2 top-[172px] z-40 w-[260px] -translate-x-1/2 rounded-[14px] border border-white/10 bg-[#262626]/98 p-2 text-white shadow-[0_24px_80px_rgba(0,0,0,0.46)] backdrop-blur-[10px]">
|
||
{quickOptions[menu].map((option) => (
|
||
<button
|
||
className="flex h-10 w-full items-center justify-between rounded-[10px] px-3 text-left text-[13px] text-white/76 hover:bg-white/8 hover:text-white"
|
||
key={option}
|
||
onClick={() => toast.info(`${option} 接口待接入。`)}
|
||
type="button"
|
||
>
|
||
{option}
|
||
<span className="text-white/28">›</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SceneToolIcon({ kind }: { kind: "panorama" | "orbit" | "lighting" | "grid" | "hd" | "crop" }) {
|
||
if (kind === "panorama") {
|
||
return <span className="font-['Barlow_Condensed',sans-serif] text-[16px] font-bold leading-none text-white/86">720</span>;
|
||
}
|
||
if (kind === "hd") {
|
||
return (
|
||
<span className="flex h-[22px] min-w-[26px] items-center justify-center rounded-[4px] border border-white/72 px-1 text-[12px] font-bold leading-none text-white/86">
|
||
HD
|
||
</span>
|
||
);
|
||
}
|
||
if (kind === "grid") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[22px] w-[22px]" fill="none" viewBox="0 0 22 22">
|
||
<path d="M3.5 4.5h15M3.5 11h15M3.5 17.5h15M4.5 3.5v15M11 3.5v15M17.5 3.5v15" stroke="currentColor" strokeLinecap="round" strokeWidth="1.7" />
|
||
</svg>
|
||
);
|
||
}
|
||
if (kind === "crop") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[22px] w-[22px]" fill="none" viewBox="0 0 22 22">
|
||
<path d="M6 3.5v12.5h12.5M3.5 6h12.5v12.5M8 8h6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
|
||
</svg>
|
||
);
|
||
}
|
||
if (kind === "lighting") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[22px] w-[22px]" fill="none" viewBox="0 0 22 22">
|
||
<path d="M4 7.5h14M6 11h10M8.5 14.5h5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.8" />
|
||
<path d="M16.5 4.5v3M15 6h3" stroke="currentColor" strokeLinecap="round" strokeWidth="1.5" />
|
||
</svg>
|
||
);
|
||
}
|
||
return (
|
||
<svg aria-hidden="true" className="h-[22px] w-[22px]" fill="none" viewBox="0 0 22 22">
|
||
<path d="M16.8 7.1a6.2 6.2 0 0 0-11.6 1.8M5.2 8.9H2.9l1.7 1.7M5.2 14.9a6.2 6.2 0 0 0 11.6-1.8M16.8 13.1h2.3l-1.7-1.7" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.8" />
|
||
<circle cx="11" cy="11" r="2.2" stroke="currentColor" strokeWidth="1.6" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: string; label: string; wide?: boolean }) {
|
||
return (
|
||
<div className={cn("relative", wide ? "w-[734px]" : "w-[556px]")}>
|
||
<div className="mb-2 flex items-center gap-1 text-[20px] font-semibold text-white/50">
|
||
<ImageIcon className="h-5 w-5" />
|
||
{label}
|
||
</div>
|
||
<div className="relative h-[410px] overflow-hidden rounded-[10px] border border-white/14 bg-white shadow-[0_18px_60px_rgba(0,0,0,0.36)]">
|
||
<img alt={label} className={cn("h-full w-full", wide ? "object-cover" : "object-contain")} src={imageUrl} />
|
||
{wide ? (
|
||
<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("上传替换素材接口待接入。")}
|
||
type="button"
|
||
>
|
||
↥
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
className="absolute -right-[28px] top-[222px] flex h-7 w-7 items-center justify-center rounded-full border border-white/45 bg-[#222] text-[22px] leading-none text-white/72"
|
||
onClick={() => toast.info(`${label}连接点接口待接入。`)}
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PromptComposer() {
|
||
const [menu, setMenu] = useState<ComposerMenu>(null);
|
||
|
||
return (
|
||
<div className="pointer-events-auto absolute bottom-[46px] left-1/2 h-[310px] w-[972px] -translate-x-1/2 rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||
<button className="absolute right-5 top-5 text-[26px] text-white/45" onClick={() => toast.info("展开提示词编辑器待接入。")} type="button">↗</button>
|
||
<div className="flex items-center gap-3">
|
||
{[
|
||
["style", "◇", "风格"],
|
||
["mark", "⌖", "标记"],
|
||
["focus", "▣", "聚焦"],
|
||
].map(([type, icon, label]) => (
|
||
<PromptActionButton
|
||
active={menu === type}
|
||
icon={icon}
|
||
key={label}
|
||
label={label}
|
||
onClick={() => setMenu((current) => current === type ? null : type as ComposerMenu)}
|
||
/>
|
||
))}
|
||
<div className="relative h-[70px] w-[70px] overflow-hidden rounded-[10px]">
|
||
<img alt="角色图缩略图" className="h-full w-full object-cover" src={CHARACTER_IMAGE_URL} />
|
||
<span className="absolute right-0 top-0 flex h-5 min-w-5 items-center justify-center rounded-bl-[8px] bg-black/50 px-1 text-xs">1</span>
|
||
</div>
|
||
</div>
|
||
{menu ? <ComposerPopover menu={menu} /> : null}
|
||
|
||
<div className="mt-6 text-[22px] text-white/46">
|
||
描述你想要生成的画面内容,按 / 呼出指令,@引用素材
|
||
</div>
|
||
|
||
<div className="absolute bottom-5 left-5 right-5 flex items-center gap-6 text-[18px] text-white/78">
|
||
<ComposerControl active={menu === "model"} label="Lib Nano Pro" icon="◉" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
|
||
<ComposerControl active={menu === "ratio"} label="16:9" icon="▭" dropdown onClick={() => setMenu((current) => current === "ratio" ? null : "ratio")} />
|
||
<ComposerControl active={menu === "resolution"} label="2K" dropdown onClick={() => setMenu((current) => current === "resolution" ? null : "resolution")} />
|
||
<ComposerControl active={menu === "camera"} label="摄像机" icon="▣" onClick={() => setMenu((current) => current === "camera" ? null : "camera")} />
|
||
<ComposerControl active={menu === "panorama"} label="全景" icon="♫" onClick={() => setMenu((current) => current === "panorama" ? null : "panorama")} />
|
||
<ComposerControl active={menu === "translate"} label="文" onClick={() => setMenu((current) => current === "translate" ? null : "translate")} />
|
||
<ComposerControl active={menu === "settings"} label="" icon="⌘" onClick={() => setMenu((current) => current === "settings" ? null : "settings")} />
|
||
<ComposerControl active={menu === "count"} label="1张" dropdown onClick={() => setMenu((current) => current === "count" ? null : "count")} />
|
||
<ComposerControl label="14" icon="⚡" muted onClick={() => toast.info("积分估算接口待接入。")} />
|
||
<button
|
||
className="ml-auto flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white"
|
||
onClick={() => toast.info("角色三视图生成接口待接入。")}
|
||
type="button"
|
||
>
|
||
↑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PromptActionButton({
|
||
active,
|
||
icon,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
active: boolean;
|
||
icon: string;
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex h-[72px] w-[74px] flex-col items-center justify-center rounded-[10px] border border-white/10 bg-[#292929] text-[15px] text-white/58",
|
||
active && "border-white/20 bg-white/10 text-white"
|
||
)}
|
||
onClick={onClick}
|
||
type="button"
|
||
>
|
||
<span className="text-[24px] leading-none">{icon}</span>
|
||
<span className="mt-2">{label}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function ComposerPopover({ menu }: { menu: Exclude<ComposerMenu, null> }) {
|
||
const options: Record<Exclude<ComposerMenu, "focus" | null>, string[]> = {
|
||
camera: ["摄像机", "手持镜头", "固定镜头"],
|
||
count: ["1张", "2张", "4张"],
|
||
mark: ["添加人物标记", "添加构图标记", "添加动作标记"],
|
||
model: ["Lib Nano Pro", "Lib Seedream Pro", "Lib Universal"],
|
||
panorama: ["全景", "半身", "近景"],
|
||
ratio: ["16:9", "9:16", "1:1", "4:3"],
|
||
resolution: ["1K", "2K", "4K"],
|
||
settings: ["提示词增强", "参考图权重", "高级参数"],
|
||
style: ["写实", "电影感", "国风", "柔和自然光"],
|
||
translate: ["中文", "English", "自动翻译"],
|
||
};
|
||
|
||
if (menu === "focus") {
|
||
return (
|
||
<div className="absolute left-[178px] top-[102px] z-50 w-[308px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-4 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
|
||
<div className="font-semibold text-[14px] text-white/86">聚焦模式</div>
|
||
<div className="mt-2 rounded-[10px] border border-dashed border-white/14 bg-white/5 px-3 py-4 text-[13px] text-white/46">
|
||
请在图片上框选聚焦区域
|
||
</div>
|
||
<button className="mt-3 h-9 w-full rounded-[10px] bg-white text-[13px] font-semibold text-black" onClick={() => toast.info("聚焦区域编辑接口待接入。")} type="button">
|
||
进入聚焦编辑
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="absolute left-5 top-[102px] z-50 w-[260px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
|
||
{options[menu].map((option) => (
|
||
<button
|
||
className="flex h-9 w-full items-center justify-between rounded-[9px] px-3 text-left text-[13px] text-white/72 hover:bg-white/8 hover:text-white"
|
||
key={option}
|
||
onClick={() => toast.info(`${option} 参数接口待接入。`)}
|
||
type="button"
|
||
>
|
||
{option}
|
||
<span className="text-white/28">›</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComposerControl({
|
||
active,
|
||
dropdown,
|
||
icon,
|
||
label,
|
||
muted = false,
|
||
onClick,
|
||
}: {
|
||
active?: boolean;
|
||
dropdown?: boolean;
|
||
icon?: string;
|
||
label: string;
|
||
muted?: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex items-center gap-2 whitespace-nowrap rounded-[8px] px-1.5 py-1 text-white/78 hover:bg-white/8",
|
||
muted && "text-white/42",
|
||
active && "bg-white/10 text-white"
|
||
)}
|
||
onClick={onClick}
|
||
type="button"
|
||
>
|
||
{icon ? <span>{icon}</span> : null}
|
||
{label ? <span>{label}</span> : null}
|
||
{dropdown ? <span className="text-white/42">⌄</span> : null}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function CloneEdges({ edges, nodes }: { edges: CloneEdge[]; nodes: CloneNode[] }) {
|
||
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
||
return (
|
||
<svg className="pointer-events-none absolute left-0 top-0 h-[4000px] w-[4000px] overflow-visible">
|
||
{edges.map((edge) => {
|
||
const source = nodeMap.get(edge.source);
|
||
const target = nodeMap.get(edge.target);
|
||
if (!source || !target) return null;
|
||
const startX = source.x + source.width;
|
||
const startY = source.y + source.height / 2;
|
||
const endX = target.x;
|
||
const endY = target.y + target.height / 2;
|
||
const curve = Math.max(90, Math.abs(endX - startX) * 0.42);
|
||
return (
|
||
<path
|
||
d={`M ${startX} ${startY} C ${startX + curve} ${startY}, ${endX - curve} ${endY}, ${endX} ${endY}`}
|
||
fill="none"
|
||
key={edge.id}
|
||
stroke="#7c86ff"
|
||
strokeLinecap="round"
|
||
strokeOpacity="0.95"
|
||
strokeWidth="4"
|
||
/>
|
||
);
|
||
})}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function CloneCanvasNode({
|
||
node,
|
||
onPointerDown,
|
||
selected,
|
||
}: {
|
||
node: CloneNode;
|
||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||
selected: boolean;
|
||
}) {
|
||
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";
|
||
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"
|
||
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" />
|
||
{isInput ? (
|
||
<div
|
||
className={cn(
|
||
"flex h-full w-full flex-col items-center justify-center 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]">
|
||
<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">
|
||
选择文件
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={cn(
|
||
"relative flex h-full w-full items-center gap-4 overflow-hidden rounded-[26px] border bg-[#f6f7fb] px-5 text-[#20222a]",
|
||
isWarm && "bg-[#fff0f3]",
|
||
node.kind === "workflow" && "bg-[#f6f7fb]",
|
||
selected ? "border-[#8b96ff]" : "border-white/45"
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-black/5 bg-white/70 text-[#8b96ff] shadow-[inset_0_0_16px_rgba(255,255,255,0.7)]",
|
||
isWarm && "text-[#ff9eae]"
|
||
)}
|
||
>
|
||
{node.kind === "workflow" ? <GitBranch className="h-5 w-5" /> : <Icon className="h-5 w-5" />}
|
||
</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>
|
||
<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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AddModulePanel({
|
||
filteredTools,
|
||
onAddTool,
|
||
onClose,
|
||
onSearchChange,
|
||
onTabChange,
|
||
search,
|
||
tab,
|
||
}: {
|
||
filteredTools: CapturedTool[];
|
||
onAddTool: (tool: CapturedTool) => void;
|
||
onClose: () => void;
|
||
onSearchChange: (value: string) => void;
|
||
onTabChange: (tab: (typeof PANEL_TABS)[number]) => void;
|
||
search: string;
|
||
tab: (typeof PANEL_TABS)[number];
|
||
}) {
|
||
return (
|
||
<section
|
||
className="absolute left-[88px] top-[110px] z-50 flex h-[min(720px,calc(100vh-140px))] w-[408px] flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.48)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="border-white/8 border-b p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-semibold text-[16px] text-white">添加节点</div>
|
||
<div className="mt-1 text-[12px] text-white/42">工具 {capturedCounts.tools || capturedTools.length} / 工作流 {capturedCounts.workflows || capturedWorkflows.length}</div>
|
||
</div>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="mt-4 flex items-center gap-2 rounded-[8px] border border-white/10 bg-black/24 px-3 py-2">
|
||
<Search className="h-4 w-4 text-white/38" />
|
||
<input
|
||
className="min-w-0 flex-1 bg-transparent text-[13px] text-white outline-none placeholder:text-white/30"
|
||
onChange={(event) => onSearchChange(event.target.value)}
|
||
placeholder="搜索模型、厂商、模式"
|
||
value={search}
|
||
/>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{PANEL_TABS.map((item) => (
|
||
<button
|
||
className={cn(
|
||
"rounded-[8px] border border-white/8 px-2.5 py-1 text-[12px] text-white/58 hover:bg-white/8 hover:text-white",
|
||
tab === item && "border-white/20 bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
key={item}
|
||
onClick={() => onTabChange(item)}
|
||
type="button"
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||
<div className="grid gap-2">
|
||
{filteredTools.map((tool) => {
|
||
const type = getToolType(tool);
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
const Icon = meta.icon;
|
||
return (
|
||
<button
|
||
className="group rounded-[8px] border border-white/8 bg-[#2a2a2a] p-3 text-left transition hover:border-white/18 hover:bg-[#323232]"
|
||
key={`${tool.toolKey || tool.modelKey}-${tool.modelName}`}
|
||
onClick={() => onAddTool(tool)}
|
||
type="button"
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px] bg-white text-black">
|
||
<Icon className="h-4 w-4" />
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate font-medium text-[14px] text-white">{getToolLabel(tool)}</span>
|
||
<span className="mt-1 flex flex-wrap gap-1.5">
|
||
<span className="rounded-[6px] bg-white/8 px-1.5 py-0.5 text-[11px] text-white/54">{tool.vendor || meta.label}</span>
|
||
{getToolModes(tool).map((mode) => (
|
||
<span className="rounded-[6px] bg-white/8 px-1.5 py-0.5 text-[11px] text-white/46" key={mode}>
|
||
{mode}
|
||
</span>
|
||
))}
|
||
</span>
|
||
</span>
|
||
<Plus className="mt-1 h-4 w-4 text-white/34 group-hover:text-white" />
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function WorkflowPanel({
|
||
onAdd,
|
||
onClose,
|
||
workflows,
|
||
}: {
|
||
onAdd: (workflow: WorkflowPreset, index: number) => void;
|
||
onClose: () => void;
|
||
workflows: WorkflowPreset[];
|
||
}) {
|
||
return (
|
||
<section
|
||
className="absolute left-[88px] top-[110px] z-50 flex h-[min(720px,calc(100vh-140px))] w-[420px] flex-col overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.48)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="flex items-center justify-between border-white/8 border-b p-4">
|
||
<div>
|
||
<div className="font-semibold text-[16px]">工作流</div>
|
||
<div className="mt-1 text-[12px] text-white/42">{workflows.length} 个预设</div>
|
||
</div>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||
<div className="grid gap-3">
|
||
{workflows.map((workflow, index) => (
|
||
<button
|
||
className="group overflow-hidden rounded-[8px] border border-white/8 bg-[#2a2a2a] text-left hover:border-white/18 hover:bg-[#323232]"
|
||
key={`${workflow.id || ""}-${workflow.uuid || workflow.name}`}
|
||
onClick={() => onAdd(workflow, index)}
|
||
type="button"
|
||
>
|
||
{workflow.cover ? (
|
||
<div
|
||
className="h-24 bg-cover bg-center"
|
||
style={{ backgroundImage: `linear-gradient(90deg, rgba(0,0,0,0.74), rgba(0,0,0,0.18)), url(${workflow.cover})` }}
|
||
/>
|
||
) : null}
|
||
<div className="p-3">
|
||
<div className="font-medium text-white">{workflow.name || workflow.title || "未命名"}</div>
|
||
<div className="mt-1 line-clamp-2 text-[12px] text-white/46">{workflow.description || "LibTV workflow"}</div>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function InspectorPanel({
|
||
node,
|
||
onChangeProperty,
|
||
onClose,
|
||
}: {
|
||
node: CloneNode;
|
||
onChangeProperty: (key: string, value: unknown) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const tool = node.tool;
|
||
const type = getToolType(tool);
|
||
const properties = getToolProperties(tool);
|
||
const showUpload = node.kind === "input" || requiresMedia(tool);
|
||
return (
|
||
<aside
|
||
className="absolute right-4 top-[124px] z-50 w-[476px] overflow-hidden rounded-[8px] border border-white/10 bg-[#202020] text-white shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="flex items-start justify-between border-white/8 border-b px-6 py-5">
|
||
<div>
|
||
<div className="font-semibold text-[18px]">{node.title}</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<span className="rounded-[6px] bg-white/8 px-2 py-1 text-[12px] text-white/58">
|
||
{node.inputType || type || node.kind}
|
||
</span>
|
||
{tool?.vendor ? (
|
||
<span className="rounded-[6px] bg-[#5b5220] px-2 py-1 text-[12px] text-[#d7c66a]">{tool.vendor}</span>
|
||
) : null}
|
||
{getToolModes(tool).map((mode) => (
|
||
<span className="rounded-[6px] bg-white/8 px-2 py-1 text-[12px] text-white/48" key={mode}>
|
||
{mode}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="space-y-4 p-6">
|
||
{showUpload ? (
|
||
<button
|
||
className="flex h-[74px] w-full items-center justify-center rounded-[8px] border border-white/10 bg-white/5 text-[14px] text-white/52 hover:bg-white/8 hover:text-white"
|
||
onClick={() => toast.info("上传/素材选择接口待接入。")}
|
||
type="button"
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
添加素材
|
||
</button>
|
||
) : null}
|
||
{properties.length > 0 ? (
|
||
<div className="space-y-4">
|
||
{properties.map(([key, config]) => (
|
||
<PropertyField
|
||
config={config}
|
||
key={key}
|
||
propertyKey={key}
|
||
value={node.draftValues?.[key]}
|
||
onChange={(value) => onChangeProperty(key, value)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-[8px] border border-white/10 bg-white/5 px-5 py-6 text-[14px] text-white/42">
|
||
此模块未暴露可配置参数。
|
||
</div>
|
||
)}
|
||
<button
|
||
className="h-[64px] w-full rounded-[8px] bg-white font-bold text-[16px] text-black hover:bg-white/90"
|
||
onClick={() => toast.info("生成接口待接入。")}
|
||
type="button"
|
||
>
|
||
生成
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function PropertyField({
|
||
config,
|
||
onChange,
|
||
propertyKey,
|
||
value,
|
||
}: {
|
||
config: unknown;
|
||
onChange: (value: unknown) => void;
|
||
propertyKey: string;
|
||
value: unknown;
|
||
}) {
|
||
const label = getPropertyLabel(propertyKey, config);
|
||
const options = getOptions(config);
|
||
const current = String(value ?? getDefaultValue(propertyKey, config));
|
||
const component = config && typeof config === "object" && "component" in config ? String((config as any).component || "") : "";
|
||
|
||
if (propertyKey === "prompt") {
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">{label}</span>
|
||
<textarea
|
||
className="min-h-[96px] w-full rounded-[8px] border border-white/10 bg-black/18 px-3 py-2 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-white/24"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
placeholder="请输入提示词"
|
||
value={String(value ?? "")}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
if (component === "switch" || typeof config === "boolean") {
|
||
const checked = current === "1" || current === "true";
|
||
return <ToggleRow checked={checked} label={label} onChange={() => onChange(checked ? "0" : "1")} />;
|
||
}
|
||
|
||
if (options.length > 0) {
|
||
if (options.length > 8) {
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">{label}</span>
|
||
<select
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-[13px] text-white outline-none"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
value={current}
|
||
>
|
||
{options.map((option) => (
|
||
<option className="bg-[#202020] text-white" key={getOptionValue(option)} value={getOptionValue(option)}>
|
||
{getOptionLabel(option)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
);
|
||
}
|
||
return (
|
||
<div>
|
||
<div className="mb-2 text-[13px] text-white/72">{label}</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{options.map((option) => {
|
||
const optionValue = getOptionValue(option);
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"rounded-[8px] border border-white/8 px-3 py-1.5 text-[12px] text-white/62 hover:bg-white/8 hover:text-white",
|
||
current === optionValue && "border-white/20 bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
key={optionValue}
|
||
onClick={() => onChange(optionValue)}
|
||
type="button"
|
||
>
|
||
{getOptionLabel(option)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (config && typeof config === "object" && "maxCount" in config) {
|
||
return (
|
||
<div>
|
||
<div className="mb-2 text-[13px] text-white/72">{label}</div>
|
||
<button
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-left text-[13px] text-white/46 hover:border-white/20 hover:text-white"
|
||
onClick={() => toast.info(`${label}选择器待接入。`)}
|
||
type="button"
|
||
>
|
||
选择{label}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">{label}</span>
|
||
<input
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-[13px] text-white outline-none"
|
||
onChange={(event) => onChange(event.target.value)}
|
||
value={String(value ?? "")}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function ToggleRow({
|
||
checked,
|
||
label,
|
||
onChange,
|
||
}: {
|
||
checked: boolean;
|
||
label: string;
|
||
onChange: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center justify-between rounded-[8px] border border-white/8 bg-white/5 px-4 py-3">
|
||
<span className="text-[13px] text-white/76">{label}</span>
|
||
<button className={cn("relative h-6 w-11 rounded-full transition", checked ? "bg-white" : "bg-white/18")} onClick={onChange} type="button">
|
||
<span className={cn("absolute top-1 h-4 w-4 rounded-full transition", checked ? "left-6 bg-black" : "left-1 bg-white")} />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AssetLibraryPanel() {
|
||
const assets = [
|
||
{ label: "角色图", type: "图片", url: CHARACTER_IMAGE_URL },
|
||
{ label: "角色三视图", type: "图片", url: CHARACTER_THREE_VIEW_URL },
|
||
{ label: "首帧", type: "图片", url: FIRST_FRAME_IMAGE_URL },
|
||
{ label: "音频参考图", type: "图片", url: AUDIO_REFERENCE_IMAGE_URL },
|
||
];
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
{["项目素材", "图片", "视频", "音频", "风格", "标记"].map((item, index) => (
|
||
<button
|
||
className={cn(
|
||
"h-8 rounded-[8px] border border-white/8 px-3 text-[12px] text-white/56 hover:bg-white/8 hover:text-white",
|
||
index === 0 && "bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
key={item}
|
||
type="button"
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
className="flex h-20 w-full items-center justify-center rounded-[8px] border border-dashed border-white/16 bg-white/5 text-[13px] text-white/54 hover:bg-white/8 hover:text-white"
|
||
onClick={() => toast.info("素材上传接口待接入。")}
|
||
type="button"
|
||
>
|
||
<Plus className="mr-2 h-4 w-4" />
|
||
上传或拖入素材
|
||
</button>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{assets.map((asset) => (
|
||
<button
|
||
className="overflow-hidden rounded-[8px] border border-white/8 bg-white/5 text-left hover:border-white/20 hover:bg-white/8"
|
||
key={asset.label}
|
||
onClick={() => toast.info(`${asset.label}选择接口待接入。`)}
|
||
type="button"
|
||
>
|
||
<div className="h-28 bg-cover bg-center" style={{ backgroundImage: `url(${asset.url})` }} />
|
||
<div className="flex items-center justify-between px-3 py-2">
|
||
<span className="text-[13px] text-white/82">{asset.label}</span>
|
||
<span className="rounded-[6px] bg-white/8 px-2 py-0.5 text-[11px] text-white/42">{asset.type}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SkillsAccessPanel() {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<div className="font-semibold text-[15px] text-white">OpenAPI Access Key</div>
|
||
<div className="mt-1 text-[12px] text-white/42">用于 LibTV Skills 调用、模型能力和自动化任务。</div>
|
||
</div>
|
||
<button
|
||
className="h-9 rounded-[8px] bg-white px-3 text-[12px] font-semibold text-black hover:bg-white/90"
|
||
onClick={() => toast.info("Access Key 创建接口待接入。")}
|
||
type="button"
|
||
>
|
||
创建 Key
|
||
</button>
|
||
</div>
|
||
<div className="mt-4 rounded-[8px] border border-white/8 bg-black/20 px-3 py-3 text-[13px] text-white/42">
|
||
暂无 Access Key
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-5 gap-2">
|
||
{Object.entries(capturedCounts.byType || {}).map(([type, count]) => {
|
||
const meta = TOOL_TYPE_META[type] || TOOL_TYPE_META.video;
|
||
return (
|
||
<div className="rounded-[8px] bg-white/6 p-3 text-center" key={type}>
|
||
<div className="text-[18px] text-white">{String(count)}</div>
|
||
<div className="mt-1 text-[11px] text-white/42">{meta.label}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MemberCenterPanel() {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="rounded-[8px] border border-[#ffc65d]/25 bg-[#2a2418] p-4">
|
||
<div className="text-[12px] text-[#fad6a4]">限时 39 折</div>
|
||
<div className="mt-1 text-[22px] font-bold text-white">会员特惠39折</div>
|
||
<div className="mt-1 text-[13px] text-white/52">会员超市、模型权益、生成额度与积分套餐待接入。</div>
|
||
<button
|
||
className="mt-4 h-10 rounded-[8px] bg-[#ffc65d] px-4 text-[13px] font-bold text-black hover:bg-[#ffd282]"
|
||
onClick={() => toast.info("会员购买接口待接入。")}
|
||
type="button"
|
||
>
|
||
查看权益
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{[
|
||
["100", "可用积分"],
|
||
["67", "模型能力"],
|
||
["25", "工作流预设"],
|
||
].map(([value, label]) => (
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-3 text-center" key={label}>
|
||
<div className="text-[20px] font-semibold text-white">{value}</div>
|
||
<div className="mt-1 text-[12px] text-white/42">{label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NotificationsPanel() {
|
||
return (
|
||
<div className="space-y-3">
|
||
{["全部", "生成任务", "系统通知", "会员活动"].map((item, index) => (
|
||
<button
|
||
className={cn(
|
||
"mr-2 h-8 rounded-[8px] border border-white/8 px-3 text-[12px] text-white/56 hover:bg-white/8 hover:text-white",
|
||
index === 0 && "bg-white text-black hover:bg-white hover:text-black"
|
||
)}
|
||
key={item}
|
||
type="button"
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
{[
|
||
["生成任务", "任务状态、进度轮询和失败原因接口待接入。"],
|
||
["系统通知", "审核、账户和平台消息接口待接入。"],
|
||
["会员活动", "会员超市与活动消息接口待接入。"],
|
||
].map(([title, description]) => (
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-3" key={title}>
|
||
<div className="text-[13px] font-semibold text-white/82">{title}</div>
|
||
<div className="mt-1 text-[12px] text-white/42">{description}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccountPanel() {
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-3 rounded-[8px] border border-white/8 bg-white/5 p-4">
|
||
<img alt="avatar" className="h-12 w-12 rounded-full object-cover" src={TARGET_AVATAR} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-[15px] font-semibold text-white">微信用户82a0d8</div>
|
||
<div className="mt-1 text-[12px] text-white/42">账号中心、团队空间和额度设置待接入。</div>
|
||
</div>
|
||
<button
|
||
className="h-9 rounded-[8px] border border-white/8 px-3 text-[12px] text-white/62 hover:bg-white/8 hover:text-white"
|
||
onClick={() => toast.info("账号设置接口待接入。")}
|
||
type="button"
|
||
>
|
||
设置
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
{[
|
||
["100", "当前积分"],
|
||
["个人空间", "当前空间"],
|
||
].map(([value, label]) => (
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-3" key={label}>
|
||
<div className="text-[18px] font-semibold text-white">{value}</div>
|
||
<div className="mt-1 text-[12px] text-white/42">{label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SupportPanel() {
|
||
return (
|
||
<div className="space-y-3">
|
||
{[
|
||
["使用教程", "打开 LibTV 视频画布教程。"],
|
||
["联系客服", "提交工单或联系在线客服。"],
|
||
["问题反馈", "反馈画布、生成和支付问题。"],
|
||
].map(([title, description]) => (
|
||
<button
|
||
className="w-full rounded-[8px] border border-white/8 bg-white/5 p-3 text-left hover:bg-white/8"
|
||
key={title}
|
||
onClick={() => toast.info(`${title}入口待接入。`)}
|
||
type="button"
|
||
>
|
||
<div className="text-[13px] font-semibold text-white/82">{title}</div>
|
||
<div className="mt-1 text-[12px] text-white/42">{description}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LibModal({
|
||
children,
|
||
onClose,
|
||
title,
|
||
width = 520,
|
||
}: {
|
||
children: React.ReactNode;
|
||
onClose: () => void;
|
||
title: string;
|
||
width?: number;
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/64 backdrop-blur-[3px]" data-canvas-overlay="true">
|
||
<div
|
||
className="max-h-[86vh] overflow-hidden rounded-[8px] border border-white/12 bg-[#202020] text-white shadow-[0_30px_100px_rgba(0,0,0,0.58)]"
|
||
style={{ width: `min(${width}px, calc(100vw - 32px))` }}
|
||
>
|
||
<div className="flex items-center justify-between border-white/8 border-b px-5 py-4">
|
||
<h2 className="font-semibold text-[16px]">{title}</h2>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="max-h-[calc(86vh-65px)] overflow-y-auto p-5">{children}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PublishModal({
|
||
onClose,
|
||
onTogglePublic,
|
||
projectTitle,
|
||
publishPublic,
|
||
}: {
|
||
onClose: () => void;
|
||
onTogglePublic: () => void;
|
||
projectTitle: string;
|
||
publishPublic: boolean;
|
||
}) {
|
||
const cover = capturedWorkflows[0]?.cover || "";
|
||
return (
|
||
<LibModal onClose={onClose} title="发布作品到 LibTV" width={480}>
|
||
<div className="space-y-4">
|
||
{cover ? (
|
||
<div className="h-20 rounded-t-[8px] bg-cover bg-center" style={{ backgroundImage: `linear-gradient(90deg, rgba(0,0,0,.72), rgba(0,0,0,.12)), url(${cover})` }} />
|
||
) : null}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<UploadBox label="选择视频" />
|
||
<UploadBox label="选择封面" hint="建议上传横版图片" />
|
||
</div>
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">作品名称 *</span>
|
||
<input className="h-10 w-full rounded-[8px] border border-white/10 bg-black/18 px-3 text-[13px] text-white outline-none" defaultValue={projectTitle || "未命名"} />
|
||
</label>
|
||
<label className="block">
|
||
<span className="mb-2 block text-[13px] text-white/72">作品描述</span>
|
||
<textarea className="min-h-[80px] w-full rounded-[8px] border border-white/10 bg-black/18 px-3 py-2 text-[13px] text-white outline-none" placeholder="请输入作品描述" />
|
||
</label>
|
||
<ToggleRow checked={publishPublic} label="公开画布" onChange={onTogglePublic} />
|
||
<button
|
||
className="h-10 w-full rounded-[8px] bg-[#2f7df6] font-semibold text-white hover:bg-[#3f8bff]"
|
||
onClick={() => toast.info("发布接口待接入。")}
|
||
type="button"
|
||
>
|
||
发布并投稿
|
||
</button>
|
||
</div>
|
||
</LibModal>
|
||
);
|
||
}
|
||
|
||
function UploadBox({ hint, label }: { hint?: string; label: string }) {
|
||
return (
|
||
<button
|
||
className="flex h-28 flex-col items-center justify-center rounded-[8px] border border-white/10 bg-white/8 text-white/56 hover:bg-white/12 hover:text-white"
|
||
onClick={() => toast.info(`${label}接口待接入。`)}
|
||
type="button"
|
||
>
|
||
<Plus className="h-5 w-5" />
|
||
<span className="mt-2 text-[13px]">{label}</span>
|
||
{hint ? <span className="mt-1 text-[11px] text-white/34">{hint}</span> : null}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function EmptyState({
|
||
description,
|
||
icon: Icon,
|
||
title,
|
||
}: {
|
||
description: string;
|
||
icon: LucideIcon;
|
||
title: string;
|
||
}) {
|
||
return (
|
||
<div className="rounded-[8px] border border-white/8 bg-white/5 p-6 text-center">
|
||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-[8px] bg-white/8 text-white/72">
|
||
<Icon className="h-5 w-5" />
|
||
</div>
|
||
<div className="mt-4 font-semibold">{title}</div>
|
||
<p className="mx-auto mt-2 max-w-[380px] text-[13px] text-white/48 leading-6">{description}</p>
|
||
</div>
|
||
);
|
||
}
|