8221 lines
313 KiB
TypeScript
8221 lines
313 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Bell,
|
||
ChevronDown,
|
||
Clock3,
|
||
Copy,
|
||
FileText,
|
||
GitBranch,
|
||
Headphones,
|
||
HelpCircle,
|
||
Image as ImageIcon,
|
||
Layers,
|
||
Loader2,
|
||
Music2,
|
||
Plus,
|
||
Play,
|
||
Scissors,
|
||
Settings2,
|
||
Sparkles,
|
||
Trash2,
|
||
Upload,
|
||
UserRound,
|
||
Video,
|
||
Wand2,
|
||
X,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { useRouter } from "next/navigation";
|
||
import {
|
||
type ChangeEvent as ReactChangeEvent,
|
||
type DragEvent as ReactDragEvent,
|
||
type MouseEvent as ReactMouseEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
type ReactNode,
|
||
type WheelEvent as ReactWheelEvent,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
import useSWR from "swr";
|
||
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
|
||
import { cn, fetcher } from "@/lib/utils";
|
||
import {
|
||
canvasService,
|
||
imageGenerationService,
|
||
videoGenerationService,
|
||
workflowAnalysisService,
|
||
} from "@/services/canvas";
|
||
import {
|
||
type VideoCanvasFunctionConfig,
|
||
type VideoCanvasFunctionDefinition,
|
||
defaultVideoCanvasFunctionConfig,
|
||
normalizeVideoCanvasFunctionConfig,
|
||
} from "@/lib/video-canvas/config";
|
||
|
||
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"
|
||
| "reference"
|
||
| "slash"
|
||
| null;
|
||
type MemberStoreTab = "vip" | "model" | "credits";
|
||
|
||
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" | "media" | "script";
|
||
|
||
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 CanvasCommandState = {
|
||
canvasX: number;
|
||
canvasY: number;
|
||
screenX: number;
|
||
screenY: number;
|
||
} | null;
|
||
|
||
type NodeContextState = {
|
||
nodeId: string;
|
||
screenX: number;
|
||
screenY: number;
|
||
} | null;
|
||
|
||
type ConnectionSide = "source" | "target";
|
||
|
||
type ConnectionMenuState = {
|
||
nodeId: string;
|
||
screenX: number;
|
||
screenY: number;
|
||
side: ConnectionSide;
|
||
} | null;
|
||
|
||
type PresetControlAnchor = {
|
||
bottomY: number;
|
||
centerX: number;
|
||
topY: number;
|
||
zoom: number;
|
||
};
|
||
|
||
type ComposerSettings = {
|
||
camera: string;
|
||
count: string;
|
||
model: string;
|
||
panorama: string;
|
||
ratio: string;
|
||
resolution: string;
|
||
};
|
||
|
||
type AddModuleKind = "text" | "image" | "video" | "video-clip" | "audio" | "script";
|
||
type CanvasCreateKind = AddModuleKind;
|
||
type TargetModuleActionLabel =
|
||
| "自己编写内容"
|
||
| "文生视频"
|
||
| "图片反推提示词"
|
||
| "文字生音乐"
|
||
| "图生图"
|
||
| "图片高清"
|
||
| "音频生视频"
|
||
| "首尾帧生成视频"
|
||
| "首帧生成视频"
|
||
| "剧本生成分镜脚本"
|
||
| "视频参考生成分镜脚本"
|
||
| "角色生成分镜脚本";
|
||
|
||
type ImageToolbarAction =
|
||
| "panorama"
|
||
| "orbit"
|
||
| "lighting"
|
||
| "grid"
|
||
| "hd"
|
||
| "crop"
|
||
| "annotate"
|
||
| "rotate"
|
||
| "download"
|
||
| "preview";
|
||
|
||
type ComposerSubmitPayload = {
|
||
prompt: string;
|
||
references: string[];
|
||
settings: ComposerSettings;
|
||
slashScene: string | null;
|
||
};
|
||
|
||
type LiblibVideoCanvasReplicaProps = {
|
||
canvasId: string;
|
||
isLoading?: boolean;
|
||
};
|
||
|
||
type SiteBrandSettings = {
|
||
siteName?: string | null;
|
||
siteLogo?: string | null;
|
||
siteLogoDark?: string | null;
|
||
};
|
||
|
||
type MembershipSummaryResponse = {
|
||
summary?: {
|
||
currentCredits?: number | null;
|
||
};
|
||
};
|
||
|
||
type GridCellVisual = {
|
||
fit?: "cover" | "contain";
|
||
objectPosition?: string;
|
||
scale?: number;
|
||
translateX?: number;
|
||
translateY?: number;
|
||
};
|
||
|
||
type GridCropRect = {
|
||
height: number;
|
||
width: number;
|
||
x: number;
|
||
y: number;
|
||
};
|
||
|
||
type GridSplitSpec = {
|
||
columns: number;
|
||
detected: boolean;
|
||
rects: GridCropRect[];
|
||
rows: number;
|
||
};
|
||
|
||
type AngleEditorSettings = {
|
||
horizontalAngle: number;
|
||
promptEnabled: boolean;
|
||
verticalAngle: number;
|
||
zoom: number;
|
||
};
|
||
|
||
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 ADD_NODE_MODULES: Array<{
|
||
badge?: string;
|
||
description: string;
|
||
icon: LucideIcon;
|
||
kind: AddModuleKind;
|
||
label: string;
|
||
}> = [
|
||
{ kind: "text", label: "文本", description: "剧本、广告词、品牌文案", icon: FileText },
|
||
{ kind: "image", label: "图片", description: "海报、分镜、角色设计", icon: ImageIcon },
|
||
{ kind: "video", label: "视频", description: "创意广告、动画、电影", icon: Video },
|
||
{ kind: "video-clip", label: "视频合成", description: "多个视频片段合为一个", icon: Scissors, badge: "Beta" },
|
||
{ kind: "audio", label: "音频", description: "音效、配音、音乐", icon: Music2 },
|
||
{ kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "Beta" },
|
||
];
|
||
|
||
const CONNECTION_ADD_MODULES: Array<{
|
||
icon: LucideIcon;
|
||
kind: Exclude<AddModuleKind, "audio">;
|
||
label: string;
|
||
}> = [
|
||
{ kind: "text", label: "文本生成", icon: FileText },
|
||
{ kind: "image", label: "图片生成", icon: ImageIcon },
|
||
{ kind: "video", label: "视频生成", icon: Video },
|
||
{ kind: "video-clip", label: "视频合成", icon: Scissors },
|
||
{ kind: "script", label: "脚本生成", icon: Layers },
|
||
];
|
||
|
||
const TARGET_MODULE_CENTER_OFFSETS: Record<AddModuleKind, { x: number; y: number }> = {
|
||
audio: { x: 705, y: -257 },
|
||
image: { x: 53, y: -257 },
|
||
script: { x: 556, y: 253 },
|
||
text: { x: -742, y: -257 },
|
||
video: { x: -574, y: 253 },
|
||
"video-clip": { x: 80, y: 253 },
|
||
};
|
||
|
||
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 PRESET_VIEWPORT = { x: 144, y: 100, zoom: 0.77 };
|
||
const PRESET_WORKFLOW_CENTER = { x: 1024, y: 512 };
|
||
const CANVAS_EXTENT = 20000;
|
||
const OFFICIAL_NODE_GAP = 100;
|
||
const OFFICIAL_TEXT_RESOURCE_HEIGHT = 232;
|
||
const OFFICIAL_TEXT_RESOURCE_WIDTH = 350;
|
||
const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7;
|
||
const OFFICIAL_COMPOSER_SCALE = 0.42;
|
||
const OFFICIAL_ZOOM_DOCK_SCALE = 0.5;
|
||
const PROMPT_COMPOSER_BASE_WIDTH = 895;
|
||
const PROMPT_COMPOSER_BASE_HEIGHT = 286;
|
||
const PRESET_COMPOSER_BASE_HEIGHT = 238;
|
||
const TEXT_MODULE_COMPOSER_WIDTH = 1274;
|
||
const TEXT_MODULE_COMPOSER_HEIGHT = 270;
|
||
const MULTI_VIEW_NINE_GRID_PROMPT =
|
||
"以选中图片为角色/主体参考,生成多视角 3x3 九宫格分镜图。保持主体一致,九个镜头包含远景、全身、侧身、背面、半身、近景、局部细节等视角,使用清晰黑色分割线分隔每一格,白色或干净背景。";
|
||
const NINE_GRID_CELL_VISUALS: GridCellVisual[] = [
|
||
{ fit: "contain", objectPosition: "50% 50%", scale: 0.62 },
|
||
{ fit: "contain", objectPosition: "58% 50%", scale: 0.74, translateX: -10 },
|
||
{ fit: "contain", objectPosition: "68% 50%", scale: 0.86, translateX: -18 },
|
||
{ objectPosition: "34% 24%", scale: 1.4, translateX: 8, translateY: 8 },
|
||
{ objectPosition: "52% 28%", scale: 1.55, translateY: 5 },
|
||
{ objectPosition: "66% 26%", scale: 1.72, translateX: -8, translateY: 2 },
|
||
{ objectPosition: "20% 72%", scale: 2.08, translateX: 22, translateY: -12 },
|
||
{ objectPosition: "52% 68%", scale: 1.24, translateY: -4 },
|
||
{ objectPosition: "70% 64%", scale: 1.46, translateX: -14, translateY: -6 },
|
||
];
|
||
|
||
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 PUBLISH_MODAL_BG_URL =
|
||
"https://libtv-res.liblib.art/upload-images/4e7115e6ee6947b78d73e0203a85fc56/771d92a21c6dce69a97513d21ee3253fe0eedec9.png";
|
||
const MEMBER_CARD_THEMES: Record<string, { bg: string; icon: string; tone: string }> = {
|
||
"lib-nano": {
|
||
bg: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/3b7bcfd5-d1d3-4545-8089-53c04bad6ede.svg",
|
||
icon: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/8d5f4fb5-8132-4049-b902-7eca7db0de1d.svg",
|
||
tone: "#2A2038",
|
||
},
|
||
"seedance-2": {
|
||
bg: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/a520a76c-8101-4be3-8c3b-30a37b2320d9.svg",
|
||
icon: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/9207509b-a5d5-47af-944b-1894b469735f.svg",
|
||
tone: "#222A3E",
|
||
},
|
||
"seedance-2-1080p": {
|
||
bg: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/a32e399e-8353-4cdf-a44e-e7e08cca8c7f.svg",
|
||
icon: "https://liblibai-online.liblib.cloud/web/4e7115e6ee6947b78d73e0203a85fc56/9207509b-a5d5-47af-944b-1894b469735f.svg",
|
||
tone: "#222222",
|
||
},
|
||
};
|
||
const MEMBER_MODEL_CARDS = [
|
||
{
|
||
title: "Seedance 2.0",
|
||
label: "1080P 专享",
|
||
power: "45,000",
|
||
price: "2,999",
|
||
corner: "首购加赠45000",
|
||
desc: "大师版及以上会员可购买",
|
||
group: "seedance-2-1080p",
|
||
},
|
||
{
|
||
title: "Seedance 2.0",
|
||
label: "1080P 专享",
|
||
power: "60,000",
|
||
price: "3,999",
|
||
corner: "首购加赠60000",
|
||
desc: "大师版及以上会员可购买",
|
||
group: "seedance-2-1080p",
|
||
},
|
||
{
|
||
title: "Lib Nano Pro",
|
||
label: "模型积分卡",
|
||
power: "20,000",
|
||
price: "1,999",
|
||
corner: "多生成571张图片",
|
||
desc: "尊享版可购买",
|
||
group: "lib-nano",
|
||
},
|
||
{
|
||
title: "Lib Nano 2",
|
||
label: "模型积分卡",
|
||
power: "45,000",
|
||
price: "2,999",
|
||
corner: "多生成1500张图片",
|
||
desc: "尊享版可购买",
|
||
group: "lib-nano",
|
||
},
|
||
{
|
||
title: "Seedance 2.0",
|
||
label: "视频模型卡",
|
||
power: "7,500",
|
||
price: "599",
|
||
corner: "多生成341秒视频",
|
||
desc: "大师版及以上会员可购买",
|
||
group: "seedance-2",
|
||
},
|
||
{
|
||
title: "Seedance 2.0",
|
||
label: "视频模型卡",
|
||
power: "15,000",
|
||
price: "999",
|
||
corner: "多生成682秒视频",
|
||
desc: "大师版及以上会员可购买",
|
||
group: "seedance-2",
|
||
},
|
||
];
|
||
const VIP_PLAN_CARDS = [
|
||
{ title: "基础版VIP", power: "每月 8,000 点积分", price: "39", benefits: ["登录每日赠送20积分", "云端存储空间20GB", "会员专属模型"] },
|
||
{ title: "大师版VIP", power: "每月 18,000 点积分", price: "89", benefits: ["无限并发任务", "会员专享无限次加速", "Seedance 2.0 活动权益"] },
|
||
{ title: "尊享版VIP", power: "每月 34,000 点积分", price: "169", benefits: ["云端存储空间500GB", "智能分镜(Kling3.0)", "去除品牌水印"] },
|
||
];
|
||
const CREDIT_PACKS = [
|
||
{ points: "1,000", price: "6", tag: "限时 6 折" },
|
||
{ points: "5,000", price: "30", tag: "首充推荐" },
|
||
{ points: "10,000", price: "60", tag: "最高赠送" },
|
||
{ points: "30,000", price: "180", tag: "创作囤货" },
|
||
];
|
||
const STORY_SCRIPT_PROMPT = "根据我上传的剧本生成一个完整的故事脚本";
|
||
const AUDIO_TO_VIDEO_PROMPT =
|
||
"根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。";
|
||
const TEXT_TO_VIDEO_PROMPT =
|
||
"爆款情绪短片\n傍晚的城市天台,一个年轻人迎着晚风张开双手,镜头从远景缓慢推进到中景,头发被风吹起,夕阳逆光,光晕明显,空气中有漂浮微尘,电影级色彩分级,浅景深,情绪张力强,慢动作";
|
||
const TEXT_TO_VIDEO_GENERATE_PROMPT = "根据文字描述生成视频。";
|
||
const TEXT_TO_MUSIC_PROMPT =
|
||
"生成一首现代品牌电子音乐,干净有力的低频贝斯,清晰电子鼓点,整体风格高级、未来感强。";
|
||
const IMAGE_TO_PROMPT_PROMPT = "根据图片生成结构化中文提示词,包括主体描述、环境、光影、镜头语言、风格关键词。";
|
||
const HD_IMAGE_SOURCE_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/5d40e509-6182-4d5d-b202-8e130b44a66c.png";
|
||
const VIDEO_FIRST_FRAME_SOURCE_URL =
|
||
"https://liblibai-online.liblib.cloud/web/28aa80df5944499487381983a8f9bbdd/604bb8d4-e8d1-439d-80b4-9867a579d0e7.png";
|
||
const VIDEO_FIRST_FRAME_PROMPT = "以当前图为首帧生成视频。";
|
||
const VIDEO_FIRST_LAST_FIRST_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/3dbb2a8e-47a7-4480-9de0-293adea8fbb4.png";
|
||
const VIDEO_FIRST_LAST_LAST_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/cea9eb03-e93f-45ab-b1c3-42d749d95321.png";
|
||
const VIDEO_FIRST_LAST_PROMPT =
|
||
"手从白帆布包中取出青柠气泡水罐的特写,过渡到短发女生在户外草地闭眼仰头喝同款气泡水的中景,日系清新治愈风格,明亮自然光,柔和暖调,胶片质感,动作流畅自然";
|
||
const SCRIPT_VIDEO_REFERENCE_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/7a21a9b9-503b-4168-b9c2-be0a3770f215.mp4";
|
||
const SCRIPT_CHARACTER_ONE_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/b6989b27-e725-4958-a062-e8dfb3312db3.png";
|
||
const SCRIPT_CHARACTER_TWO_URL =
|
||
"https://liblibai-online.liblib.cloud/web/ea95cb51f3d94addbb92dc003cd33b49/fe840bb1-649e-4664-a23d-30f5ecf8e36c.png";
|
||
const SCRIPT_CHARACTER_PROMPT =
|
||
"根据角色参考图生成分镜脚本,围绕旧城区、机械修复、暴雨停电和被重新点亮的街道展开,突出人物关系、镜头画面和声音设计。";
|
||
const SLASH_IMAGE_ITEMS = [
|
||
"剧情推演四宫格",
|
||
"25宫格连贯分镜",
|
||
"电影级光影校正",
|
||
"角色三视图生成",
|
||
"画面推演-3秒后",
|
||
"画面推演-5秒前",
|
||
];
|
||
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 getInspectorTitle(node: CloneNode) {
|
||
if (node.kind !== "input") return node.title;
|
||
if (node.inputType === "video") return "视频输入";
|
||
if (node.inputType === "audio") return "音频输入";
|
||
if (node.inputType === "text") 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, index) => {
|
||
const metadata = ((item as any)?.data?.metadata || {}) as Record<string, unknown>;
|
||
const clone = metadata.liblibCloneNode as CloneNode | undefined;
|
||
return clone && typeof clone.id === "string" ? normalizeRestoredNode(clone, index + 1) : 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: {},
|
||
};
|
||
}
|
||
|
||
function createTargetModuleNode(kind: AddModuleKind, x: number, y: number, index: number): CloneNode {
|
||
if (kind === "script") {
|
||
return {
|
||
id: makeId("script"),
|
||
kind: "script",
|
||
title: `脚本 ${index}`,
|
||
subtitle: "脚本生成",
|
||
inputType: "text",
|
||
x,
|
||
y,
|
||
width: 438,
|
||
height: 470,
|
||
accent: "#f0d06b",
|
||
draftValues: {
|
||
action: "SCRIPT_GENERATE",
|
||
generatorType: "DEFAULT",
|
||
nodeType: "script",
|
||
rows: [],
|
||
scriptRole: "generator",
|
||
viewMode: "table",
|
||
},
|
||
};
|
||
}
|
||
|
||
if (kind === "video-clip") {
|
||
return {
|
||
id: makeId("video-clip"),
|
||
kind: "model",
|
||
title: `视频合成 ${index}`,
|
||
subtitle: "视频合成",
|
||
inputType: "video",
|
||
x,
|
||
y,
|
||
width: 436,
|
||
height: 470,
|
||
accent: "#ffb8c8",
|
||
draftValues: {
|
||
action: "VIDEO_CLIP_RESOURCE",
|
||
nodeType: "video-clip",
|
||
params: {},
|
||
url: [],
|
||
},
|
||
};
|
||
}
|
||
|
||
const meta: Record<Exclude<AddModuleKind, "script" | "video-clip">, {
|
||
action: string;
|
||
accent: string;
|
||
kind: CloneNodeKind;
|
||
nodeType: NonNullable<CloneNode["inputType"]>;
|
||
subtitle: string;
|
||
title: string;
|
||
}> = {
|
||
audio: {
|
||
action: "AUDIO_GENERATE",
|
||
accent: "#9ee6bb",
|
||
kind: "audio",
|
||
nodeType: "audio",
|
||
subtitle: "音频生成",
|
||
title: `音频节点 ${index}`,
|
||
},
|
||
image: {
|
||
action: "IMAGE_GENERATE",
|
||
accent: "#8b96ff",
|
||
kind: "model",
|
||
nodeType: "image",
|
||
subtitle: "图片生成",
|
||
title: `图片节点 ${index}`,
|
||
},
|
||
text: {
|
||
action: "TEXT_GENERATE",
|
||
accent: "#f0d06b",
|
||
kind: "text",
|
||
nodeType: "text",
|
||
subtitle: "文本生成",
|
||
title: `文本节点 ${index}`,
|
||
},
|
||
video: {
|
||
action: "VIDEO_GENERATE",
|
||
accent: "#8b96ff",
|
||
kind: "model",
|
||
nodeType: "video",
|
||
subtitle: "视频生成",
|
||
title: `视频节点 ${index}`,
|
||
},
|
||
};
|
||
const item = meta[kind];
|
||
const isWideModule = item.nodeType === "image" || item.nodeType === "video";
|
||
return {
|
||
id: makeId(kind),
|
||
kind: item.kind,
|
||
title: item.title,
|
||
subtitle: item.subtitle,
|
||
inputType: item.nodeType,
|
||
x,
|
||
y,
|
||
width: isWideModule ? 773 : 436,
|
||
height: 470,
|
||
accent: item.accent,
|
||
draftValues: {
|
||
action: item.action,
|
||
alt: item.nodeType === "image" ? `图片 ${index}` : undefined,
|
||
content: item.nodeType === "text" ? [] : undefined,
|
||
generatorType: "DEFAULT",
|
||
nodeType: item.nodeType,
|
||
poster: item.nodeType === "video" ? "" : undefined,
|
||
url: item.nodeType === "text" ? undefined : [],
|
||
},
|
||
};
|
||
}
|
||
|
||
function createUploadedMediaNode(file: File, mediaUrl: string, x: number, y: number, index: number): CloneNode {
|
||
const mediaType: NonNullable<CloneNode["inputType"]> = file.type.startsWith("video/")
|
||
? "video"
|
||
: file.type.startsWith("audio/")
|
||
? "audio"
|
||
: "image";
|
||
const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : "图片";
|
||
const fallbackName = `${label}节点 ${index}`;
|
||
const title = file.name.replace(/\.[^.]+$/, "").trim() || fallbackName;
|
||
|
||
return {
|
||
id: makeId(mediaType),
|
||
kind: "media",
|
||
title,
|
||
subtitle: `${label}资源`,
|
||
inputType: mediaType,
|
||
x,
|
||
y,
|
||
width: mediaType === "audio" ? 430 : 420,
|
||
height: mediaType === "audio" ? 170 : 280,
|
||
accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
|
||
draftValues: {
|
||
action: `${mediaType.toUpperCase()}_RESOURCE`,
|
||
alt: file.name,
|
||
generated: false,
|
||
mediaType,
|
||
mediaUrl,
|
||
nodeType: mediaType,
|
||
resourceMeta: {
|
||
items: [{ name: file.name, size: file.size, type: file.type }],
|
||
},
|
||
url: [mediaUrl],
|
||
},
|
||
};
|
||
}
|
||
|
||
function createMediaResourceNode({
|
||
action,
|
||
height,
|
||
mediaType,
|
||
mediaUrl,
|
||
title,
|
||
width,
|
||
x,
|
||
y,
|
||
prompt,
|
||
}: {
|
||
action?: string;
|
||
height?: number;
|
||
mediaType: NonNullable<CloneNode["inputType"]>;
|
||
mediaUrl: string;
|
||
prompt?: string;
|
||
title: string;
|
||
width?: number;
|
||
x: number;
|
||
y: number;
|
||
}): CloneNode {
|
||
const label = mediaType === "video" ? "视频" : mediaType === "audio" ? "音频" : mediaType === "text" ? "文本" : "图片";
|
||
return {
|
||
id: makeId(mediaType),
|
||
kind: "media",
|
||
title,
|
||
subtitle: `${label}资源`,
|
||
inputType: mediaType,
|
||
x,
|
||
y,
|
||
width: width ?? (mediaType === "audio" ? 430 : mediaType === "video" ? 560 : 420),
|
||
height: height ?? (mediaType === "audio" ? 170 : mediaType === "video" ? 315 : 280),
|
||
accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
|
||
draftValues: {
|
||
action: action ?? `${mediaType.toUpperCase()}_RESOURCE`,
|
||
alt: title,
|
||
generated: false,
|
||
mediaType,
|
||
mediaUrl,
|
||
nodeType: mediaType,
|
||
params: prompt ? { prompt } : undefined,
|
||
prompt: prompt || "",
|
||
url: [mediaUrl],
|
||
},
|
||
};
|
||
}
|
||
|
||
function createNineGridCompositeNode({
|
||
anchor,
|
||
columns,
|
||
option,
|
||
rows,
|
||
sourceUrl,
|
||
x,
|
||
y,
|
||
}: {
|
||
anchor: CloneNode;
|
||
columns: number;
|
||
option?: string;
|
||
rows: number;
|
||
sourceUrl: string;
|
||
x: number;
|
||
y: number;
|
||
}): CloneNode {
|
||
const cellCount = rows * columns;
|
||
const title =
|
||
cellCount === 9
|
||
? "多机位九宫格"
|
||
: option?.trim() || `${cellCount}宫格`;
|
||
const gridPrompt =
|
||
cellCount === 9 && (!option || /多|9|九/.test(option))
|
||
? MULTI_VIEW_NINE_GRID_PROMPT
|
||
: `基于选中图片生成 ${columns}x${rows} 宫格分镜图,使用黑色分割线分隔每一格。`;
|
||
|
||
return {
|
||
id: makeId("image"),
|
||
kind: "media",
|
||
title,
|
||
subtitle: "生成结果",
|
||
inputType: "image",
|
||
x,
|
||
y,
|
||
width: 560,
|
||
height: 347,
|
||
accent: "#8790ff",
|
||
draftValues: {
|
||
action: "IMAGE_GENERATE_NINE_GRID",
|
||
generated: true,
|
||
gridColumns: columns,
|
||
gridPrompt,
|
||
gridRows: rows,
|
||
mediaType: "image",
|
||
nineGridCells: Array.from({ length: cellCount }).map((_, index) => getGridCellVisual(index)),
|
||
nineGridComposite: true,
|
||
nodeType: "image",
|
||
outputSizeLabel: cellCount === 9 ? "2752 × 1536" : "",
|
||
params: {
|
||
columns,
|
||
mode: "grid",
|
||
option: option || "",
|
||
prompt: gridPrompt,
|
||
rows,
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
},
|
||
prompt: gridPrompt,
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
toolbarOption: option || "",
|
||
url: [],
|
||
},
|
||
};
|
||
}
|
||
|
||
function createTextResourceNode({
|
||
content,
|
||
height = OFFICIAL_TEXT_RESOURCE_HEIGHT,
|
||
title,
|
||
width = OFFICIAL_TEXT_RESOURCE_WIDTH,
|
||
x,
|
||
y,
|
||
}: {
|
||
content: string;
|
||
height?: number;
|
||
title: string;
|
||
width?: number;
|
||
x: number;
|
||
y: number;
|
||
}): CloneNode {
|
||
return {
|
||
id: makeId("text"),
|
||
kind: "script",
|
||
title,
|
||
subtitle: "文本资源",
|
||
inputType: "text",
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
accent: "#f0d06b",
|
||
draftValues: {
|
||
action: "TEXT_RESOURCE",
|
||
content: [content],
|
||
nodeType: "text",
|
||
scriptRole: "resource",
|
||
viewMode: undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
function getDraftString(node: CloneNode | null | undefined, key: string) {
|
||
const value = node?.draftValues?.[key];
|
||
return typeof value === "string" ? value : "";
|
||
}
|
||
|
||
function getNodeMediaUrl(node: CloneNode | null | undefined) {
|
||
const direct = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
|
||
if (direct) return direct;
|
||
const urls = node?.draftValues?.url;
|
||
return Array.isArray(urls) && typeof urls[0] === "string" ? urls[0] : "";
|
||
}
|
||
|
||
function preventNativeImageDrag(event: ReactDragEvent<HTMLImageElement>) {
|
||
event.preventDefault();
|
||
}
|
||
|
||
function normalizeGridOption(option?: string) {
|
||
const text = String(option || "").trim();
|
||
const tileCount =
|
||
/25\s*宫格|5\s*[x×]\s*5|25/.test(text)
|
||
? 25
|
||
: /16\s*宫格|4\s*[x×]\s*4|16/.test(text)
|
||
? 16
|
||
: /4\s*宫格|2\s*[x×]\s*2|四/.test(text)
|
||
? 4
|
||
: 9;
|
||
const columns = tileCount === 4 ? 2 : tileCount === 16 ? 4 : tileCount === 25 ? 5 : 3;
|
||
const rows = Math.ceil(tileCount / columns);
|
||
return { columns, rows, tileCount };
|
||
}
|
||
|
||
function getGridCellVisual(index: number): GridCellVisual {
|
||
return NINE_GRID_CELL_VISUALS[index % NINE_GRID_CELL_VISUALS.length];
|
||
}
|
||
|
||
function getNodeSourceMediaUrl(node: CloneNode | null | undefined) {
|
||
return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node);
|
||
}
|
||
|
||
function getConfiguredFunction(
|
||
config: VideoCanvasFunctionConfig,
|
||
key: string,
|
||
): VideoCanvasFunctionDefinition {
|
||
return config.functions[key] || defaultVideoCanvasFunctionConfig.functions[key];
|
||
}
|
||
|
||
function getFunctionModel(
|
||
config: VideoCanvasFunctionConfig,
|
||
definition: VideoCanvasFunctionDefinition,
|
||
) {
|
||
const fallback =
|
||
definition.kind === "video"
|
||
? config.defaultVideoModel
|
||
: definition.kind === "multimodal"
|
||
? config.defaultMultimodalModel
|
||
: config.defaultImageModel;
|
||
const model = (definition.model || fallback || "").trim();
|
||
return model && model !== "auto" ? model : undefined;
|
||
}
|
||
|
||
function composeFunctionPrompt(
|
||
definition: VideoCanvasFunctionDefinition,
|
||
userPrompt?: string,
|
||
) {
|
||
const base = definition.prompt.trim();
|
||
const extra = String(userPrompt || "").trim();
|
||
if (!base) return extra;
|
||
if (!extra || extra === base) return base;
|
||
return `${base}\n\n用户补充:${extra}`;
|
||
}
|
||
|
||
function getFirstGeneratedImageUrl(
|
||
response: Awaited<ReturnType<typeof imageGenerationService.generateImage>>,
|
||
) {
|
||
const first = response.data?.images?.[0];
|
||
return first?.originalUrl || first?.thumbnailUrl || "";
|
||
}
|
||
|
||
function createEqualGridRects(rows: number, columns: number): GridCropRect[] {
|
||
return Array.from({ length: rows * columns }).map((_, index) => {
|
||
const column = index % columns;
|
||
const row = Math.floor(index / columns);
|
||
return {
|
||
height: 1 / rows,
|
||
width: 1 / columns,
|
||
x: column / columns,
|
||
y: row / rows,
|
||
};
|
||
});
|
||
}
|
||
|
||
function buildGridRectsFromLines({
|
||
columns,
|
||
height,
|
||
horizontalLines,
|
||
rows,
|
||
verticalLines,
|
||
width,
|
||
}: {
|
||
columns: number;
|
||
height: number;
|
||
horizontalLines: number[];
|
||
rows: number;
|
||
verticalLines: number[];
|
||
width: number;
|
||
}): GridCropRect[] {
|
||
const lineMargin = Math.max(1, Math.round(Math.min(width, height) * 0.0015));
|
||
const xBounds = [0, ...verticalLines, width];
|
||
const yBounds = [0, ...horizontalLines, height];
|
||
const rects: GridCropRect[] = [];
|
||
|
||
for (let row = 0; row < rows; row += 1) {
|
||
for (let column = 0; column < columns; column += 1) {
|
||
const left = column === 0 ? xBounds[column] : xBounds[column] + lineMargin;
|
||
const right = column === columns - 1 ? xBounds[column + 1] : xBounds[column + 1] - lineMargin;
|
||
const top = row === 0 ? yBounds[row] : yBounds[row] + lineMargin;
|
||
const bottom = row === rows - 1 ? yBounds[row + 1] : yBounds[row + 1] - lineMargin;
|
||
rects.push({
|
||
height: Math.max((bottom - top) / height, 0.02),
|
||
width: Math.max((right - left) / width, 0.02),
|
||
x: Math.max(left / width, 0),
|
||
y: Math.max(top / height, 0),
|
||
});
|
||
}
|
||
}
|
||
|
||
return rects;
|
||
}
|
||
|
||
function getLineScore(data: Uint8ClampedArray, width: number, height: number, axis: "x" | "y", index: number) {
|
||
const limit = axis === "x" ? height : width;
|
||
const step = Math.max(1, Math.floor(limit / 260));
|
||
let score = 0;
|
||
let samples = 0;
|
||
|
||
for (let point = 0; point < limit; point += step) {
|
||
const x = axis === "x" ? index : point;
|
||
const y = axis === "x" ? point : index;
|
||
const offset = (y * width + x) * 4;
|
||
const alpha = data[offset + 3] / 255;
|
||
if (alpha < 0.1) continue;
|
||
const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114;
|
||
score += luminance < 96 ? ((96 - luminance) / 96) * alpha : 0;
|
||
samples += 1;
|
||
}
|
||
|
||
return samples > 0 ? score / samples : 0;
|
||
}
|
||
|
||
function findGridDividerLines(
|
||
data: Uint8ClampedArray,
|
||
width: number,
|
||
height: number,
|
||
count: number,
|
||
axis: "x" | "y"
|
||
) {
|
||
const size = axis === "x" ? width : height;
|
||
const otherSize = axis === "x" ? height : width;
|
||
const lines: number[] = [];
|
||
let detectedCount = 0;
|
||
|
||
for (let divider = 1; divider < count; divider += 1) {
|
||
const expected = (divider / count) * size;
|
||
const searchRadius = Math.max(8, Math.round(size * 0.08));
|
||
const from = Math.max(1, Math.round(expected - searchRadius));
|
||
const to = Math.min(size - 2, Math.round(expected + searchRadius));
|
||
let bestIndex = Math.round(expected);
|
||
let bestScore = 0;
|
||
|
||
for (let index = from; index <= to; index += 1) {
|
||
const score =
|
||
getLineScore(data, width, height, axis, index - 1) * 0.25 +
|
||
getLineScore(data, width, height, axis, index) * 0.5 +
|
||
getLineScore(data, width, height, axis, index + 1) * 0.25;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestIndex = index;
|
||
}
|
||
}
|
||
|
||
const minimumScore = otherSize > 0 ? 0.18 : 0.22;
|
||
if (bestScore >= minimumScore) {
|
||
detectedCount += 1;
|
||
lines.push(bestIndex);
|
||
} else {
|
||
lines.push(Math.round(expected));
|
||
}
|
||
}
|
||
|
||
return {
|
||
detected: detectedCount === Math.max(0, count - 1),
|
||
lines,
|
||
};
|
||
}
|
||
|
||
async function detectGridSplitFromImage(url: string, rows: number, columns: number): Promise<GridSplitSpec | null> {
|
||
if (!url || typeof window === "undefined") return null;
|
||
|
||
try {
|
||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const nextImage = new Image();
|
||
if (!url.startsWith("blob:") && !url.startsWith("data:")) {
|
||
nextImage.crossOrigin = "anonymous";
|
||
nextImage.referrerPolicy = "no-referrer";
|
||
}
|
||
nextImage.onload = () => resolve(nextImage);
|
||
nextImage.onerror = () => reject(new Error("image-load-failed"));
|
||
nextImage.src = url;
|
||
});
|
||
const naturalWidth = image.naturalWidth || image.width;
|
||
const naturalHeight = image.naturalHeight || image.height;
|
||
if (!naturalWidth || !naturalHeight) return null;
|
||
|
||
const maxSide = 980;
|
||
const scale = Math.min(1, maxSide / Math.max(naturalWidth, naturalHeight));
|
||
const width = Math.max(1, Math.round(naturalWidth * scale));
|
||
const height = Math.max(1, Math.round(naturalHeight * scale));
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||
if (!context) return null;
|
||
|
||
context.drawImage(image, 0, 0, width, height);
|
||
const imageData = context.getImageData(0, 0, width, height);
|
||
const verticalResult = findGridDividerLines(imageData.data, width, height, columns, "x");
|
||
const horizontalResult = findGridDividerLines(imageData.data, width, height, rows, "y");
|
||
const rects = buildGridRectsFromLines({
|
||
columns,
|
||
height,
|
||
horizontalLines: horizontalResult.lines,
|
||
rows,
|
||
verticalLines: verticalResult.lines,
|
||
width,
|
||
});
|
||
|
||
return {
|
||
columns,
|
||
detected: verticalResult.detected && horizontalResult.detected,
|
||
rects,
|
||
rows,
|
||
};
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getPresetMode(node: CloneNode | null | undefined): ActiveStarterScene {
|
||
const value = getDraftString(node, "targetPreset");
|
||
if (
|
||
value === "story-script" ||
|
||
value === "character-three-view" ||
|
||
value === "first-frame" ||
|
||
value === "audio-to-video"
|
||
) {
|
||
return value;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isOfficialImageControlNode(node: CloneNode | null | undefined): node is CloneNode {
|
||
return !!node && (node.kind === "media" || node.kind === "script") && !!getPresetMode(node);
|
||
}
|
||
|
||
function getTargetModuleKind(node: CloneNode): AddModuleKind | null {
|
||
if (node.kind === "media" || getPresetMode(node)) return null;
|
||
if (getDraftString(node, "action") === "TEXT_RESOURCE" || getDraftString(node, "scriptRole") === "resource") {
|
||
return null;
|
||
}
|
||
const nodeType = getDraftString(node, "nodeType");
|
||
if (
|
||
nodeType === "text" ||
|
||
nodeType === "image" ||
|
||
nodeType === "video" ||
|
||
nodeType === "video-clip" ||
|
||
nodeType === "audio" ||
|
||
nodeType === "script"
|
||
) {
|
||
return nodeType;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function extractTrailingNumber(value: string | undefined) {
|
||
const match = String(value || "").match(/(\d+)\s*$/);
|
||
return match ? Number.parseInt(match[1], 10) : null;
|
||
}
|
||
|
||
function inferLegacyTargetModuleKind(node: CloneNode): AddModuleKind | null {
|
||
if (node.kind === "media" || getPresetMode(node)) return null;
|
||
const action = getDraftString(node, "action");
|
||
if (action.endsWith("_RESOURCE") || action === "TEXT_RESOURCE") return null;
|
||
|
||
const nodeType = getDraftString(node, "nodeType");
|
||
if (nodeType === "text" || nodeType === "image" || nodeType === "video" || nodeType === "audio" || nodeType === "script") {
|
||
return nodeType;
|
||
}
|
||
if (nodeType === "video-clip" || nodeType === "video_clip" || nodeType === "videoGroup") return "video-clip";
|
||
|
||
const haystack = `${node.title} ${node.subtitle || ""} ${node.kind} ${node.inputType || ""} ${action}`.toLowerCase();
|
||
if (/脚本|script/.test(haystack)) return "script";
|
||
if (/视频合成|视频组|video-clip|video_clip|video_group|clip/.test(haystack)) return "video-clip";
|
||
if (/音频|audio/.test(haystack)) return "audio";
|
||
if (/图片|image/.test(haystack)) return "image";
|
||
if (/视频|video/.test(haystack)) return "video";
|
||
if (/文本|文字|text/.test(haystack)) return "text";
|
||
return null;
|
||
}
|
||
|
||
function normalizeTextResourceNode(node: CloneNode): CloneNode {
|
||
const shouldUseOfficialSize =
|
||
Boolean(node.draftValues?.manualEditing) || /^(文本|文本生成|文本节点)/.test(String(node.title || ""));
|
||
return {
|
||
...node,
|
||
kind: "script",
|
||
title: node.title && !/文本生成|文本节点/.test(node.title) ? node.title : "文本",
|
||
subtitle: "文本资源",
|
||
inputType: "text",
|
||
width: shouldUseOfficialSize ? OFFICIAL_TEXT_RESOURCE_WIDTH : node.width > 260 ? node.width : OFFICIAL_TEXT_RESOURCE_WIDTH,
|
||
height: shouldUseOfficialSize ? OFFICIAL_TEXT_RESOURCE_HEIGHT : node.height > 120 ? node.height : OFFICIAL_TEXT_RESOURCE_HEIGHT,
|
||
accent: "#f0d06b",
|
||
draftValues: {
|
||
...(node.draftValues || {}),
|
||
action: "TEXT_RESOURCE",
|
||
content: Array.isArray(node.draftValues?.content) ? node.draftValues?.content : [],
|
||
nodeType: "text",
|
||
scriptRole: "resource",
|
||
viewMode: undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
function shouldResetTargetModuleTitle(title: string | undefined, kind: AddModuleKind) {
|
||
const value = String(title || "").trim();
|
||
if (!value) return true;
|
||
const legacyTitlePattern =
|
||
/^(文本|文本生成|文本节点|图片|图片生成|图片节点|视频|视频生成|视频节点|音频|音频生成|音频节点|脚本|脚本生成|脚本生成器|视频合成|视频合成节点)(\s*\d+)?$/;
|
||
if (!legacyTitlePattern.test(value)) return false;
|
||
if (kind === "video-clip") return !/^视频合成\s*\d+$/.test(value);
|
||
const officialPrefix =
|
||
kind === "text"
|
||
? "文本节点"
|
||
: kind === "image"
|
||
? "图片节点"
|
||
: kind === "video"
|
||
? "视频节点"
|
||
: kind === "audio"
|
||
? "音频节点"
|
||
: "脚本";
|
||
return !new RegExp(`^${officialPrefix}\\s*\\d+$`).test(value);
|
||
}
|
||
|
||
function normalizeTargetModuleNode(node: CloneNode, kind: AddModuleKind, fallbackIndex: number): CloneNode {
|
||
const index = extractTrailingNumber(node.title) || fallbackIndex;
|
||
const official = createTargetModuleNode(kind, node.x, node.y, index);
|
||
const isOfficialShape = Math.abs(node.width - official.width) <= 2 && Math.abs(node.height - official.height) <= 2;
|
||
const resetTitle = shouldResetTargetModuleTitle(node.title, kind);
|
||
|
||
if (isOfficialShape && !resetTitle) return node;
|
||
|
||
return {
|
||
...official,
|
||
id: node.id,
|
||
x: node.x,
|
||
y: node.y,
|
||
title: resetTitle ? official.title : node.title,
|
||
draftValues: {
|
||
...(official.draftValues || {}),
|
||
...(node.draftValues || {}),
|
||
nodeType: kind,
|
||
},
|
||
};
|
||
}
|
||
|
||
function normalizeRestoredNode(node: CloneNode, fallbackIndex: number): CloneNode {
|
||
const action = getDraftString(node, "action");
|
||
if (action === "TEXT_RESOURCE" && !getPresetMode(node)) {
|
||
return normalizeTextResourceNode(node);
|
||
}
|
||
|
||
const kind = getTargetModuleKind(node) || inferLegacyTargetModuleKind(node);
|
||
if (!kind) return node;
|
||
|
||
return normalizeTargetModuleNode(node, kind, fallbackIndex);
|
||
}
|
||
|
||
function createPresetMediaNode({
|
||
action,
|
||
generated = false,
|
||
height,
|
||
mediaType,
|
||
mediaUrl,
|
||
preset,
|
||
prompt,
|
||
title,
|
||
width,
|
||
x,
|
||
y,
|
||
}: {
|
||
action: string;
|
||
generated?: boolean;
|
||
height: number;
|
||
mediaType: NonNullable<CloneNode["inputType"]>;
|
||
mediaUrl?: string;
|
||
preset: Exclude<ActiveStarterScene, null>;
|
||
prompt?: string;
|
||
title: string;
|
||
width: number;
|
||
x: number;
|
||
y: number;
|
||
}): CloneNode {
|
||
return {
|
||
id: makeId(mediaType),
|
||
kind: "media",
|
||
title,
|
||
subtitle: generated ? "生成结果" : mediaType === "audio" ? "音频资源" : "素材资源",
|
||
inputType: mediaType,
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
accent: mediaType === "audio" ? "#9ee6bb" : mediaType === "video" ? "#8b96ff" : "#8790ff",
|
||
draftValues: {
|
||
action,
|
||
generated,
|
||
mediaType,
|
||
mediaUrl: mediaUrl || "",
|
||
params: prompt ? { prompt } : undefined,
|
||
prompt: prompt || "",
|
||
targetPreset: preset,
|
||
url: mediaUrl ? [mediaUrl] : [],
|
||
},
|
||
};
|
||
}
|
||
|
||
function createPresetScriptNode({
|
||
preset,
|
||
role,
|
||
title,
|
||
x,
|
||
y,
|
||
}: {
|
||
preset: Exclude<ActiveStarterScene, null>;
|
||
role: "resource" | "generator";
|
||
title: string;
|
||
x: number;
|
||
y: number;
|
||
}): CloneNode {
|
||
const isGenerator = role === "generator";
|
||
return {
|
||
id: makeId(isGenerator ? "script" : "text"),
|
||
kind: "script",
|
||
title,
|
||
subtitle: isGenerator ? "脚本生成器" : "文本资源",
|
||
inputType: "text",
|
||
x,
|
||
y,
|
||
width: 560,
|
||
height: 430,
|
||
accent: "#f0d06b",
|
||
draftValues: {
|
||
action: isGenerator ? "SCRIPT_GENERATE" : "TEXT_RESOURCE",
|
||
content: isGenerator ? [] : [STORY_SCRIPT_TEXT],
|
||
params: isGenerator ? { count: 1, prompt: STORY_SCRIPT_PROMPT } : undefined,
|
||
prompt: isGenerator ? STORY_SCRIPT_PROMPT : "",
|
||
rows: isGenerator ? [] : undefined,
|
||
scriptRole: role,
|
||
targetPreset: preset,
|
||
viewMode: isGenerator ? "table" : undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
function makePresetEdges(leftNodes: CloneNode[], rightNode: CloneNode): CloneEdge[] {
|
||
return leftNodes.map((node) => ({
|
||
id: makeId("edge"),
|
||
source: node.id,
|
||
target: rightNode.id,
|
||
}));
|
||
}
|
||
|
||
function createStarterWorkflow(
|
||
starterTitle: string,
|
||
center: { x: number; y: number }
|
||
): { nodes: CloneNode[]; edges: CloneEdge[]; selectedId: string } | null {
|
||
if (starterTitle === "故事脚本生成") {
|
||
const preset = "story-script";
|
||
const left = createPresetScriptNode({
|
||
preset,
|
||
role: "resource",
|
||
title: "剧本",
|
||
x: Math.round(center.x - 610),
|
||
y: Math.round(center.y - 320),
|
||
});
|
||
const right = createPresetScriptNode({
|
||
preset,
|
||
role: "generator",
|
||
title: "脚本生成器",
|
||
x: Math.round(center.x + 50),
|
||
y: Math.round(center.y - 320),
|
||
});
|
||
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
|
||
}
|
||
|
||
if (starterTitle === "角色三视图") {
|
||
const preset = "character-three-view";
|
||
const distance = 100;
|
||
const left = createPresetMediaNode({
|
||
action: "IMAGE_RESOURCE",
|
||
height: 520,
|
||
mediaType: "image",
|
||
mediaUrl: CHARACTER_IMAGE_URL,
|
||
preset,
|
||
title: "角色图",
|
||
width: 874,
|
||
x: Math.round(center.x - 874 - distance / 2),
|
||
y: Math.round(center.y - 320),
|
||
});
|
||
const right = createPresetMediaNode({
|
||
action: "IMAGE_GENERATE",
|
||
generated: true,
|
||
height: 520,
|
||
mediaType: "image",
|
||
mediaUrl: CHARACTER_THREE_VIEW_URL,
|
||
preset,
|
||
title: "角色三视图",
|
||
width: 660,
|
||
x: Math.round(center.x + distance / 2),
|
||
y: Math.round(center.y - 320),
|
||
});
|
||
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
|
||
}
|
||
|
||
if (starterTitle === "首帧图生视频") {
|
||
const preset = "first-frame";
|
||
const left = createPresetMediaNode({
|
||
action: "IMAGE_RESOURCE",
|
||
height: 430,
|
||
mediaType: "image",
|
||
mediaUrl: FIRST_FRAME_IMAGE_URL,
|
||
preset,
|
||
title: "首帧",
|
||
width: 620,
|
||
x: Math.round(center.x - 675),
|
||
y: Math.round(center.y - 310),
|
||
});
|
||
const right = createPresetMediaNode({
|
||
action: "VIDEO_GENERATE",
|
||
generated: true,
|
||
height: 430,
|
||
mediaType: "video",
|
||
preset,
|
||
title: "视频",
|
||
width: 560,
|
||
x: Math.round(center.x + 55),
|
||
y: Math.round(center.y - 310),
|
||
});
|
||
return { nodes: [left, right], edges: makePresetEdges([left], right), selectedId: right.id };
|
||
}
|
||
|
||
if (starterTitle === "音频生视频") {
|
||
const preset = "audio-to-video";
|
||
const leftX = Math.round(center.x - 900);
|
||
const leftY = Math.round(center.y - 370);
|
||
const audio = createPresetMediaNode({
|
||
action: "AUDIO_RESOURCE",
|
||
height: 190,
|
||
mediaType: "audio",
|
||
mediaUrl: AUDIO_SOURCE_URL,
|
||
preset,
|
||
title: "音频",
|
||
width: 720,
|
||
x: leftX,
|
||
y: leftY,
|
||
});
|
||
const image = createPresetMediaNode({
|
||
action: "IMAGE_RESOURCE",
|
||
height: 400,
|
||
mediaType: "image",
|
||
mediaUrl: AUDIO_REFERENCE_IMAGE_URL,
|
||
preset,
|
||
title: "图片",
|
||
width: 720,
|
||
x: leftX,
|
||
y: leftY + 300,
|
||
});
|
||
const right = createPresetMediaNode({
|
||
action: "VIDEO_GENERATE",
|
||
generated: true,
|
||
height: 430,
|
||
mediaType: "video",
|
||
preset,
|
||
prompt: AUDIO_TO_VIDEO_PROMPT,
|
||
title: "视频",
|
||
width: 760,
|
||
x: Math.round(center.x + 90),
|
||
y: Math.round(center.y - 300),
|
||
});
|
||
return { nodes: [audio, image, right], edges: makePresetEdges([audio, image], right), selectedId: right.id };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: LiblibVideoCanvasReplicaProps) {
|
||
const router = useRouter();
|
||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||
const dragRef = useRef<DragState | null>(null);
|
||
const { data: siteBrandSettings } = useSWR<SiteBrandSettings>("/api/site-settings", fetcher);
|
||
const { data: videoCanvasFunctionSettings } = useSWR<{ config?: VideoCanvasFunctionConfig }>(
|
||
"/api/video-canvas/config",
|
||
fetcher
|
||
);
|
||
const videoCanvasFunctionConfig = useMemo(
|
||
() => normalizeVideoCanvasFunctionConfig(videoCanvasFunctionSettings?.config),
|
||
[videoCanvasFunctionSettings?.config]
|
||
);
|
||
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 [isProjectLoading, setIsProjectLoading] = useState(true);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [, setShareEnabled] = useState(false);
|
||
const [publishPublic, setPublishPublic] = useState(false);
|
||
const [canvasCommand, setCanvasCommand] = useState<CanvasCommandState>(null);
|
||
const [nodeContextMenu, setNodeContextMenu] = useState<NodeContextState>(null);
|
||
const [connectionMenu, setConnectionMenu] = useState<ConnectionMenuState>(null);
|
||
const [previewNode, setPreviewNode] = useState<CloneNode | null>(null);
|
||
const [angleEditorNodeId, setAngleEditorNodeId] = useState<string | null>(null);
|
||
const [dockOverviewOpen, setDockOverviewOpen] = useState(false);
|
||
const [snapEnabled, setSnapEnabled] = useState(true);
|
||
|
||
const selectedNode = useMemo(
|
||
() => nodes.find((node) => node.id === selectedNodeId) || null,
|
||
[nodes, selectedNodeId]
|
||
);
|
||
|
||
const contextNode = useMemo(
|
||
() => nodes.find((node) => node.id === nodeContextMenu?.nodeId) || null,
|
||
[nodeContextMenu?.nodeId, nodes]
|
||
);
|
||
|
||
const connectionNode = useMemo(
|
||
() => nodes.find((node) => node.id === connectionMenu?.nodeId) || null,
|
||
[connectionMenu?.nodeId, nodes]
|
||
);
|
||
|
||
const angleEditorNode = useMemo(
|
||
() => nodes.find((node) => node.id === angleEditorNodeId) || null,
|
||
[angleEditorNodeId, nodes]
|
||
);
|
||
|
||
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 addWorkflowPreset = useCallback(
|
||
(workflow: WorkflowPreset, index = 0) => {
|
||
const title = String(workflow.name || workflow.title || "工具箱预设");
|
||
const baseX = 360 + index * 36;
|
||
const baseY = 300 + index * 24;
|
||
const reference = createMediaResourceNode({
|
||
action: "IMAGE_RESOURCE",
|
||
height: 280,
|
||
mediaType: "image",
|
||
mediaUrl: workflow.cover || CHARACTER_IMAGE_URL,
|
||
title: "参考图",
|
||
width: 420,
|
||
x: baseX,
|
||
y: baseY,
|
||
});
|
||
const result = createMediaResourceNode({
|
||
action: "VIDEO_GENERATE",
|
||
height: 315,
|
||
mediaType: "video",
|
||
mediaUrl: "",
|
||
prompt: workflow.description || `${title} 预设工作流`,
|
||
title,
|
||
width: 560,
|
||
x: baseX + 520,
|
||
y: baseY - 18,
|
||
});
|
||
const presetNode: CloneNode = {
|
||
...result,
|
||
subtitle: "工具箱工作流",
|
||
draftValues: {
|
||
...(result.draftValues || {}),
|
||
generated: true,
|
||
params: {
|
||
prompt: workflow.description || `${title} 预设工作流`,
|
||
presetId: workflow.uuid || workflow.id || "",
|
||
presetName: title,
|
||
sourceNodeId: reference.id,
|
||
},
|
||
sourceNodeId: reference.id,
|
||
workflow,
|
||
},
|
||
};
|
||
addNodes([reference, presetNode], [{ id: makeId("edge"), source: reference.id, target: presetNode.id }]);
|
||
setActivePanel(null);
|
||
setConnectionMenu(null);
|
||
toast.success(`已添加「${title}」工具箱工作流`);
|
||
},
|
||
[addNodes]
|
||
);
|
||
|
||
const toCanvasPoint = useCallback(
|
||
(clientX: number, clientY: number) => ({
|
||
x: Math.round((clientX - viewport.x) / viewport.zoom),
|
||
y: Math.round((clientY - viewport.y) / viewport.zoom),
|
||
}),
|
||
[viewport.x, viewport.y, viewport.zoom]
|
||
);
|
||
|
||
const addTargetModule = useCallback(
|
||
(kind: AddModuleKind, point?: { x: number; y: number }) => {
|
||
const viewportCenter = toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||
const offset = TARGET_MODULE_CENTER_OFFSETS[kind];
|
||
const sameKindCount = nodes.filter((node) => getTargetModuleKind(node) === kind).length;
|
||
const center = point || {
|
||
x: viewportCenter.x + offset.x + sameKindCount * 28,
|
||
y: viewportCenter.y + offset.y + sameKindCount * 28,
|
||
};
|
||
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
|
||
const node = {
|
||
...created,
|
||
x: Math.round(center.x - created.width / 2),
|
||
y: Math.round(center.y - created.height / 2),
|
||
};
|
||
addNodes([node], []);
|
||
setActivePanel(null);
|
||
setCanvasCommand(null);
|
||
setConnectionMenu(null);
|
||
toast.success(`已添加 ${node.title}`);
|
||
},
|
||
[addNodes, nodes.length, toCanvasPoint]
|
||
);
|
||
|
||
const addUploadedFilesAtPoint = useCallback(
|
||
(files: File[], center: { x: number; y: number }) => {
|
||
if (files.length === 0) return;
|
||
const newNodes = files.slice(0, 10).map((file, index) => {
|
||
const created = createUploadedMediaNode(file, URL.createObjectURL(file), 0, 0, nodes.length + index + 1);
|
||
return {
|
||
...created,
|
||
x: Math.round(center.x - created.width / 2 + index * 450),
|
||
y: Math.round(center.y - created.height / 2),
|
||
};
|
||
});
|
||
addNodes(newNodes, []);
|
||
setActivePanel(null);
|
||
setCanvasCommand(null);
|
||
setConnectionMenu(null);
|
||
toast.success(files.length === 1 ? `已添加 ${newNodes[0]?.title || "素材"}` : `已添加 ${newNodes.length} 个素材`);
|
||
},
|
||
[addNodes, nodes.length]
|
||
);
|
||
|
||
const addUploadedFiles = useCallback(
|
||
(files: File[]) => {
|
||
addUploadedFilesAtPoint(files, toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2));
|
||
},
|
||
[addUploadedFilesAtPoint, toCanvasPoint]
|
||
);
|
||
|
||
const addCanvasCommandUploadedFiles = useCallback(
|
||
(files: File[]) => {
|
||
if (!canvasCommand) return;
|
||
addUploadedFilesAtPoint(files, { x: canvasCommand.canvasX, y: canvasCommand.canvasY });
|
||
},
|
||
[addUploadedFilesAtPoint, canvasCommand]
|
||
);
|
||
|
||
const openCanvasCommand = useCallback(
|
||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
|
||
event.preventDefault();
|
||
const point = toCanvasPoint(event.clientX, event.clientY);
|
||
setSelectedNodeId(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
setCanvasCommand({
|
||
canvasX: point.x,
|
||
canvasY: point.y,
|
||
screenX: event.clientX,
|
||
screenY: event.clientY,
|
||
});
|
||
},
|
||
[toCanvasPoint]
|
||
);
|
||
|
||
const openNodeContext = useCallback((event: ReactMouseEvent<HTMLDivElement>, node: CloneNode) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setSelectedNodeId(node.id);
|
||
setCanvasCommand(null);
|
||
setConnectionMenu(null);
|
||
setNodeContextMenu({
|
||
nodeId: node.id,
|
||
screenX: event.clientX,
|
||
screenY: event.clientY,
|
||
});
|
||
}, []);
|
||
|
||
const openConnectionMenu = useCallback((event: ReactMouseEvent<HTMLButtonElement>, node: CloneNode, side: ConnectionSide) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setSelectedNodeId(node.id);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
setConnectionMenu({
|
||
nodeId: node.id,
|
||
screenX: event.clientX,
|
||
screenY: event.clientY,
|
||
side,
|
||
});
|
||
}, []);
|
||
|
||
const addConnectedModule = useCallback(
|
||
(kind: Exclude<AddModuleKind, "audio">) => {
|
||
if (!connectionMenu) return;
|
||
const anchor = nodes.find((node) => node.id === connectionMenu.nodeId);
|
||
if (!anchor) return;
|
||
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
|
||
const gap = OFFICIAL_NODE_GAP;
|
||
const node =
|
||
connectionMenu.side === "source"
|
||
? {
|
||
...created,
|
||
x: Math.round(anchor.x + anchor.width + gap),
|
||
y: Math.round(anchor.y + anchor.height / 2 - created.height / 2),
|
||
}
|
||
: {
|
||
...created,
|
||
x: Math.round(anchor.x - created.width - gap),
|
||
y: Math.round(anchor.y + anchor.height / 2 - created.height / 2),
|
||
};
|
||
const edge =
|
||
connectionMenu.side === "source"
|
||
? { id: makeId("edge"), source: anchor.id, target: node.id }
|
||
: { id: makeId("edge"), source: node.id, target: anchor.id };
|
||
|
||
addNodes([node], [edge]);
|
||
setConnectionMenu(null);
|
||
toast.success(`已添加并连接 ${node.title}`);
|
||
},
|
||
[addNodes, connectionMenu, nodes]
|
||
);
|
||
|
||
const runTargetModuleAction = useCallback(
|
||
(nodeId: string, actionLabel: TargetModuleActionLabel) => {
|
||
const anchor = nodes.find((node) => node.id === nodeId);
|
||
if (!anchor) return;
|
||
|
||
const leftOfAnchor = (width: number) => Math.round(anchor.x - width - OFFICIAL_NODE_GAP);
|
||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||
const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2);
|
||
const nextNodes: CloneNode[] = [];
|
||
const nextEdges: CloneEdge[] = [];
|
||
let selectedId = anchor.id;
|
||
let anchorUpdates: Partial<CloneNode> = {};
|
||
let draftUpdates: Record<string, unknown> = {};
|
||
|
||
const connectToAnchor = (node: CloneNode) => {
|
||
nextNodes.push(node);
|
||
nextEdges.push({ id: makeId("edge"), source: node.id, target: anchor.id });
|
||
selectedId = node.id;
|
||
};
|
||
|
||
const connectFromAnchor = (node: CloneNode) => {
|
||
nextNodes.push(node);
|
||
nextEdges.push({ id: makeId("edge"), source: anchor.id, target: node.id });
|
||
selectedId = node.id;
|
||
};
|
||
|
||
const makeGenerated = (kind: Exclude<AddModuleKind, "audio"> | "audio", title: string, x: number, updates?: Record<string, unknown>) => {
|
||
const created = createTargetModuleNode(kind, x, 0, nodes.length + nextNodes.length + 1);
|
||
return {
|
||
...created,
|
||
title,
|
||
y: centerY(created.height),
|
||
draftValues: {
|
||
...(created.draftValues || {}),
|
||
...(updates || {}),
|
||
},
|
||
};
|
||
};
|
||
|
||
switch (actionLabel) {
|
||
case "自己编写内容": {
|
||
draftUpdates = {
|
||
action: "TEXT_GENERATE",
|
||
content: Array.isArray(anchor.draftValues?.content) ? anchor.draftValues?.content : [],
|
||
manualEditing: true,
|
||
manualTextComposer: true,
|
||
nodeType: "text",
|
||
};
|
||
selectedId = anchor.id;
|
||
toast.success("已打开文本编写对话框");
|
||
break;
|
||
}
|
||
case "文生视频": {
|
||
draftUpdates = { action: "TEXT_GENERATE", content: [TEXT_TO_VIDEO_PROMPT] };
|
||
connectFromAnchor(makeGenerated("video", "视频", rightX, { params: { prompt: TEXT_TO_VIDEO_GENERATE_PROMPT } }));
|
||
break;
|
||
}
|
||
case "图片反推提示词": {
|
||
const image = createMediaResourceNode({
|
||
mediaType: "image",
|
||
mediaUrl: FIRST_FRAME_IMAGE_URL,
|
||
title: "图片",
|
||
width: 420,
|
||
height: 280,
|
||
x: leftOfAnchor(420),
|
||
y: centerY(280),
|
||
});
|
||
draftUpdates = {
|
||
action: "TEXT_GENERATE",
|
||
content: [],
|
||
params: { ...(anchor.draftValues?.params as Record<string, unknown> | undefined), prompt: IMAGE_TO_PROMPT_PROMPT },
|
||
};
|
||
connectToAnchor(image);
|
||
break;
|
||
}
|
||
case "文字生音乐": {
|
||
draftUpdates = { action: "TEXT_GENERATE", content: [TEXT_TO_MUSIC_PROMPT] };
|
||
connectFromAnchor(makeGenerated("audio", "音频", rightX, { params: { scene: "Music" } }));
|
||
break;
|
||
}
|
||
case "图生图": {
|
||
anchorUpdates = {
|
||
kind: "media",
|
||
title: "图片",
|
||
subtitle: "图片资源",
|
||
width: 420,
|
||
height: 280,
|
||
inputType: "image",
|
||
};
|
||
draftUpdates = {
|
||
action: "IMAGE_RESOURCE",
|
||
generated: false,
|
||
mediaType: "image",
|
||
mediaUrl: CHARACTER_IMAGE_URL,
|
||
nodeType: "image",
|
||
url: [CHARACTER_IMAGE_URL],
|
||
};
|
||
connectFromAnchor(makeGenerated("image", "图片生成", rightX));
|
||
break;
|
||
}
|
||
case "图片高清": {
|
||
anchorUpdates = {
|
||
kind: "media",
|
||
title: "图片",
|
||
subtitle: "图片资源",
|
||
width: 420,
|
||
height: 280,
|
||
inputType: "image",
|
||
};
|
||
draftUpdates = {
|
||
action: "IMAGE_RESOURCE",
|
||
generated: false,
|
||
mediaType: "image",
|
||
mediaUrl: HD_IMAGE_SOURCE_URL,
|
||
nodeType: "image",
|
||
url: [HD_IMAGE_SOURCE_URL],
|
||
};
|
||
connectFromAnchor(makeGenerated("image", "高清", rightX, { generatorType: "ENHANCE" }));
|
||
break;
|
||
}
|
||
case "音频生视频": {
|
||
anchorUpdates = {
|
||
kind: "media",
|
||
title: "音频",
|
||
subtitle: "音频资源",
|
||
width: 720,
|
||
height: 190,
|
||
inputType: "audio",
|
||
};
|
||
draftUpdates = {
|
||
action: "AUDIO_RESOURCE",
|
||
generated: false,
|
||
mediaType: "audio",
|
||
mediaUrl: AUDIO_SOURCE_URL,
|
||
nodeType: "audio",
|
||
url: [AUDIO_SOURCE_URL],
|
||
};
|
||
const video = {
|
||
...makeGenerated("video", "视频", Math.round(anchor.x + 720 + OFFICIAL_NODE_GAP), { params: { prompt: AUDIO_TO_VIDEO_PROMPT } }),
|
||
width: 760,
|
||
height: 430,
|
||
y: Math.round(anchor.y + 70),
|
||
};
|
||
const image = createMediaResourceNode({
|
||
mediaType: "image",
|
||
mediaUrl: AUDIO_REFERENCE_IMAGE_URL,
|
||
title: "图片",
|
||
width: 720,
|
||
height: 400,
|
||
x: anchor.x,
|
||
y: Math.round(anchor.y + 300),
|
||
});
|
||
nextNodes.push(video, image);
|
||
nextEdges.push(
|
||
{ id: makeId("edge"), source: anchor.id, target: video.id },
|
||
{ id: makeId("edge"), source: image.id, target: video.id }
|
||
);
|
||
selectedId = video.id;
|
||
break;
|
||
}
|
||
case "首帧生成视频": {
|
||
draftUpdates = { action: "VIDEO_GENERATE", params: { prompt: VIDEO_FIRST_FRAME_PROMPT } };
|
||
connectToAnchor(
|
||
createMediaResourceNode({
|
||
mediaType: "image",
|
||
mediaUrl: VIDEO_FIRST_FRAME_SOURCE_URL,
|
||
title: "首帧",
|
||
width: 420,
|
||
height: 280,
|
||
x: leftOfAnchor(420),
|
||
y: centerY(280),
|
||
})
|
||
);
|
||
selectedId = anchor.id;
|
||
break;
|
||
}
|
||
case "首尾帧生成视频": {
|
||
draftUpdates = { action: "VIDEO_GENERATE", params: { prompt: VIDEO_FIRST_LAST_PROMPT } };
|
||
const first = createMediaResourceNode({
|
||
mediaType: "image",
|
||
mediaUrl: VIDEO_FIRST_LAST_FIRST_URL,
|
||
title: "首帧",
|
||
width: 420,
|
||
height: 240,
|
||
x: leftOfAnchor(420),
|
||
y: Math.round(anchor.y + 18),
|
||
});
|
||
const last = createMediaResourceNode({
|
||
mediaType: "image",
|
||
mediaUrl: VIDEO_FIRST_LAST_LAST_URL,
|
||
title: "尾帧",
|
||
width: 420,
|
||
height: 240,
|
||
x: leftOfAnchor(420),
|
||
y: Math.round(anchor.y + 286),
|
||
});
|
||
connectToAnchor(first);
|
||
connectToAnchor(last);
|
||
selectedId = anchor.id;
|
||
break;
|
||
}
|
||
case "剧本生成分镜脚本": {
|
||
connectToAnchor(
|
||
createTextResourceNode({
|
||
content: STORY_SCRIPT_TEXT,
|
||
title: "文本",
|
||
x: leftOfAnchor(OFFICIAL_TEXT_RESOURCE_WIDTH),
|
||
y: centerY(OFFICIAL_TEXT_RESOURCE_HEIGHT),
|
||
})
|
||
);
|
||
selectedId = anchor.id;
|
||
break;
|
||
}
|
||
case "视频参考生成分镜脚本": {
|
||
draftUpdates = { action: "SCRIPT_GENERATE", params: { prompt: "解析视频内容生成分镜脚本。" } };
|
||
connectToAnchor(
|
||
createMediaResourceNode({
|
||
action: "VIDEO_RESOURCE",
|
||
mediaType: "video",
|
||
mediaUrl: SCRIPT_VIDEO_REFERENCE_URL,
|
||
prompt: "解析视频内容生成分镜脚本。",
|
||
title: "视频",
|
||
width: 560,
|
||
height: 315,
|
||
x: leftOfAnchor(560),
|
||
y: centerY(315),
|
||
})
|
||
);
|
||
selectedId = anchor.id;
|
||
break;
|
||
}
|
||
case "角色生成分镜脚本": {
|
||
draftUpdates = { action: "SCRIPT_GENERATE", params: { prompt: SCRIPT_CHARACTER_PROMPT } };
|
||
const first = createMediaResourceNode({
|
||
mediaType: "image",
|
||
mediaUrl: SCRIPT_CHARACTER_ONE_URL,
|
||
title: "角色 1",
|
||
width: 300,
|
||
height: 300,
|
||
x: leftOfAnchor(300),
|
||
y: Math.round(anchor.y + 10),
|
||
});
|
||
const second = createMediaResourceNode({
|
||
mediaType: "image",
|
||
mediaUrl: SCRIPT_CHARACTER_TWO_URL,
|
||
title: "角色 2",
|
||
width: 300,
|
||
height: 300,
|
||
x: leftOfAnchor(300),
|
||
y: Math.round(anchor.y + 330),
|
||
});
|
||
connectToAnchor(first);
|
||
connectToAnchor(second);
|
||
selectedId = anchor.id;
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
|
||
setNodes((current) => [
|
||
...current.map((item) =>
|
||
item.id === anchor.id
|
||
? {
|
||
...item,
|
||
...anchorUpdates,
|
||
draftValues: {
|
||
...(item.draftValues || {}),
|
||
...draftUpdates,
|
||
},
|
||
}
|
||
: item
|
||
),
|
||
...nextNodes,
|
||
]);
|
||
if (nextEdges.length > 0) {
|
||
setEdges((current) => [...current, ...nextEdges]);
|
||
}
|
||
setSelectedNodeId(selectedId);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setCanvasCommand(null);
|
||
setActivePanel(null);
|
||
if (actionLabel !== "自己编写内容") toast.success(`已按「${actionLabel}」创建关联节点`);
|
||
},
|
||
[nodes]
|
||
);
|
||
|
||
const runSelectedImageToolAction = useCallback(
|
||
async (nodeId: string, action: ImageToolbarAction, option?: string) => {
|
||
const anchor = nodes.find((node) => node.id === nodeId);
|
||
if (!anchor) return;
|
||
const sourceUrl = getNodeSourceMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||
|
||
if (action === "download") {
|
||
if (!sourceUrl) {
|
||
toast.error("当前素材没有可下载地址");
|
||
return;
|
||
}
|
||
const link = document.createElement("a");
|
||
link.href = sourceUrl;
|
||
link.download = `${anchor.title || "libtv-image"}.png`;
|
||
link.rel = "noreferrer";
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
toast.success("已开始下载当前素材");
|
||
return;
|
||
}
|
||
|
||
if (action === "preview") {
|
||
setPreviewNode(anchor);
|
||
return;
|
||
}
|
||
|
||
if (action === "annotate") {
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === anchor.id
|
||
? {
|
||
...node,
|
||
draftValues: {
|
||
...(node.draftValues || {}),
|
||
annotationMode: true,
|
||
annotations: node.draftValues?.annotations || [{ label: "主体", x: 42, y: 46 }],
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
);
|
||
setSelectedNodeId(anchor.id);
|
||
toast.success("已进入标注模式");
|
||
return;
|
||
}
|
||
|
||
if (action === "rotate") {
|
||
const currentRotation = Number(anchor.draftValues?.rotation || 0);
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === anchor.id
|
||
? {
|
||
...node,
|
||
draftValues: {
|
||
...(node.draftValues || {}),
|
||
rotation: (currentRotation + 90) % 360,
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
);
|
||
setSelectedNodeId(anchor.id);
|
||
toast.success("已旋转当前素材");
|
||
return;
|
||
}
|
||
|
||
if (action === "orbit") {
|
||
setSelectedNodeId(anchor.id);
|
||
setAngleEditorNodeId(anchor.id);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
return;
|
||
}
|
||
|
||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||
const centerY = (height: number) => Math.round(anchor.y + anchor.height / 2 - height / 2);
|
||
const nextNodes: CloneNode[] = [];
|
||
const nextEdges: CloneEdge[] = [];
|
||
|
||
if (action === "crop") {
|
||
const { columns, rows, tileCount } = normalizeGridOption(option);
|
||
const tileSize = tileCount === 25 ? 142 : tileCount === 16 ? 156 : 180;
|
||
const gap = 26;
|
||
const startY = Math.round(anchor.y + anchor.height / 2 - (rows * tileSize + (rows - 1) * gap) / 2);
|
||
const isSyntheticGrid = Boolean(anchor.draftValues?.nineGridComposite);
|
||
const anchorMediaUrl = getNodeMediaUrl(anchor);
|
||
const detectedSplit =
|
||
!isSyntheticGrid && anchorMediaUrl
|
||
? await detectGridSplitFromImage(anchorMediaUrl, rows, columns)
|
||
: null;
|
||
const rects =
|
||
detectedSplit?.rects && detectedSplit.rects.length >= tileCount
|
||
? detectedSplit.rects
|
||
: createEqualGridRects(rows, columns);
|
||
const sourceVisuals = Array.isArray(anchor.draftValues?.nineGridCells)
|
||
? (anchor.draftValues?.nineGridCells as GridCellVisual[])
|
||
: [];
|
||
|
||
Array.from({ length: tileCount }).forEach((_, index) => {
|
||
const tile = createMediaResourceNode({
|
||
action: "IMAGE_RESOURCE",
|
||
height: tileSize,
|
||
mediaType: "image",
|
||
mediaUrl: isSyntheticGrid ? sourceUrl : anchorMediaUrl || sourceUrl,
|
||
prompt: option || "宫格切分",
|
||
title: `镜头 ${String(index + 1).padStart(2, "0")}`,
|
||
width: tileSize,
|
||
x: rightX + (index % columns) * (tileSize + gap),
|
||
y: startY + Math.floor(index / columns) * (tileSize + gap),
|
||
});
|
||
nextNodes.push({
|
||
...tile,
|
||
subtitle: "宫格切片",
|
||
draftValues: {
|
||
...(tile.draftValues || {}),
|
||
cropTile: {
|
||
columns,
|
||
detectedDivider: Boolean(detectedSplit?.detected),
|
||
index,
|
||
rect: rects[index],
|
||
rows,
|
||
sourceCompositeNodeId: anchor.id,
|
||
sourceMode: isSyntheticGrid ? "synthetic-grid" : "detected-lines",
|
||
},
|
||
gridCellVisual: isSyntheticGrid ? sourceVisuals[index] || getGridCellVisual(index) : undefined,
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
},
|
||
});
|
||
nextEdges.push({ id: makeId("edge"), source: anchor.id, target: tile.id });
|
||
});
|
||
|
||
setNodes((current) => [...current, ...nextNodes]);
|
||
setEdges((current) => [...current, ...nextEdges]);
|
||
setSelectedNodeId(nextNodes[0]?.id || anchor.id);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
toast.success(
|
||
detectedSplit?.detected
|
||
? `已识别黑色分割线并切分为 ${tileCount} 张图片`
|
||
: `已按 ${columns}x${rows} 宫格切分为 ${tileCount} 张图片`
|
||
);
|
||
return;
|
||
}
|
||
|
||
const labels: Record<Exclude<ImageToolbarAction, "annotate" | "rotate" | "download" | "preview" | "crop" | "orbit">, string> = {
|
||
grid: "九宫格",
|
||
hd: "高清",
|
||
lighting: "打光",
|
||
panorama: "720 全景",
|
||
};
|
||
const title = labels[action];
|
||
if (action === "grid") {
|
||
const { columns, rows } = normalizeGridOption(option || "9宫格");
|
||
const gridDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "multi-view-nine-grid");
|
||
let gridNode = createNineGridCompositeNode({
|
||
anchor,
|
||
columns,
|
||
option,
|
||
rows,
|
||
sourceUrl,
|
||
x: rightX,
|
||
y: centerY(347),
|
||
});
|
||
if (videoCanvasFunctionConfig.enabled && gridDefinition?.enabled) {
|
||
const prompt = composeFunctionPrompt(gridDefinition, option);
|
||
const response = await imageGenerationService.generateImage({
|
||
aspectRatio: "16:9",
|
||
imageCount: 1,
|
||
model: getFunctionModel(videoCanvasFunctionConfig, gridDefinition),
|
||
prompt,
|
||
referenceImage: sourceUrl,
|
||
});
|
||
const generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||
if (generatedUrl) {
|
||
gridNode = {
|
||
...gridNode,
|
||
draftValues: {
|
||
...(gridNode.draftValues || {}),
|
||
generated: true,
|
||
mediaUrl: generatedUrl,
|
||
prompt,
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
url: [generatedUrl],
|
||
},
|
||
};
|
||
} else if (!response.success) {
|
||
toast.error(response.error || "九宫格生成失败,已保留可切分占位节点");
|
||
}
|
||
}
|
||
setNodes((current) => [...current, gridNode]);
|
||
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: gridNode.id }]);
|
||
setSelectedNodeId(gridNode.id);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
toast.success("已按预设提示词生成多机位九宫格结果图");
|
||
return;
|
||
}
|
||
|
||
const functionKey =
|
||
action === "lighting"
|
||
? "lighting"
|
||
: action === "panorama"
|
||
? "panorama-720"
|
||
: /扩/.test(option || "")
|
||
? "image-outpaint"
|
||
: /重绘/.test(option || "")
|
||
? "image-repaint"
|
||
: /擦/.test(option || "")
|
||
? "image-erase"
|
||
: /抠/.test(option || "")
|
||
? "remove-bg"
|
||
: "hd-upscale";
|
||
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
|
||
const prompt = composeFunctionPrompt(definition, option?.trim() || title);
|
||
|
||
if (videoCanvasFunctionConfig.enabled && definition?.enabled) {
|
||
let generatedUrl = "";
|
||
if (definition.route === "enhance") {
|
||
const response = await imageGenerationService.enhanceImage(sourceUrl);
|
||
generatedUrl = response.success ? response.data?.url || "" : "";
|
||
if (!generatedUrl && !response.success) toast.error(response.error || "高清处理失败");
|
||
} else if (definition.route === "extend") {
|
||
const response = await imageGenerationService.extendImage(sourceUrl, { prompt });
|
||
generatedUrl = response.success ? response.data?.url || "" : "";
|
||
if (!generatedUrl && !response.success) toast.error(response.error || "扩图处理失败");
|
||
} else if (definition.route === "remove-bg") {
|
||
const response = await imageGenerationService.removeBackground(sourceUrl);
|
||
generatedUrl = response.success ? response.data?.url || "" : "";
|
||
if (!generatedUrl && !response.success) toast.error(response.error || "抠图处理失败");
|
||
} else if (definition.route === "canvas-generate-image") {
|
||
const response = await imageGenerationService.generateImage({
|
||
aspectRatio: "16:9",
|
||
imageCount: 1,
|
||
model: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||
prompt,
|
||
referenceImage: sourceUrl,
|
||
});
|
||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||
if (!generatedUrl && !response.success) toast.error(response.error || `${definition.label}处理失败`);
|
||
}
|
||
|
||
if (generatedUrl) {
|
||
const resultNode = createMediaResourceNode({
|
||
action: "IMAGE_RESOURCE",
|
||
height: 280,
|
||
mediaType: "image",
|
||
mediaUrl: generatedUrl,
|
||
prompt,
|
||
title: definition.label || title,
|
||
width: 420,
|
||
x: rightX,
|
||
y: centerY(280),
|
||
});
|
||
setNodes((current) => [
|
||
...current,
|
||
{
|
||
...resultNode,
|
||
draftValues: {
|
||
...(resultNode.draftValues || {}),
|
||
generated: true,
|
||
params: {
|
||
mode: action,
|
||
prompt,
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
},
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
toolbarOption: option || "",
|
||
},
|
||
},
|
||
]);
|
||
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: resultNode.id }]);
|
||
setSelectedNodeId(resultNode.id);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
toast.success(`已生成「${definition.label || title}」结果`);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1);
|
||
const resultNode: CloneNode = {
|
||
...created,
|
||
title,
|
||
y: centerY(created.height),
|
||
draftValues: {
|
||
...(created.draftValues || {}),
|
||
generatorType: action === "hd" ? "ENHANCE" : "DEFAULT",
|
||
params: {
|
||
mode: action,
|
||
prompt,
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
},
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
toolbarOption: option || "",
|
||
},
|
||
};
|
||
|
||
setNodes((current) => [...current, resultNode]);
|
||
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: resultNode.id }]);
|
||
setSelectedNodeId(anchor.id);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
toast.success(`已创建「${title}」工作流节点`);
|
||
},
|
||
[nodes, videoCanvasFunctionConfig]
|
||
);
|
||
|
||
const updateTextResourceContent = useCallback((nodeId: string, content: string) => {
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === nodeId
|
||
? {
|
||
...node,
|
||
draftValues: {
|
||
...(node.draftValues || {}),
|
||
content: content ? [content] : [],
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
);
|
||
}, []);
|
||
|
||
const submitSelectedImagePrompt = useCallback(
|
||
async (nodeId: string, payload: ComposerSubmitPayload) => {
|
||
const anchor = nodes.find((node) => node.id === nodeId);
|
||
if (!anchor) return;
|
||
const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL;
|
||
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
|
||
const starterMode = getPresetMode(anchor);
|
||
const functionKey =
|
||
payload.slashScene && /九宫格|宫格|多机位/.test(payload.slashScene)
|
||
? "multi-view-nine-grid"
|
||
: starterMode === "character-three-view"
|
||
? "character-three-view"
|
||
: "image-repaint";
|
||
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
|
||
const prompt = composeFunctionPrompt(
|
||
definition,
|
||
payload.prompt.trim() || payload.slashScene || "生成角色三视图",
|
||
);
|
||
const count = Math.max(1, Math.min(4, Number.parseInt(payload.settings.count, 10) || 1));
|
||
let generatedUrl = "";
|
||
|
||
if (videoCanvasFunctionConfig.enabled && definition?.enabled) {
|
||
const response = await imageGenerationService.generateImage({
|
||
aspectRatio: payload.settings.ratio,
|
||
imageCount: count,
|
||
model: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||
prompt,
|
||
referenceImage: sourceUrl,
|
||
});
|
||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||
if (!generatedUrl && !response.success) {
|
||
toast.error(response.error || `${definition.label}生成失败,已保留生成节点`);
|
||
}
|
||
}
|
||
|
||
const generatedNode = generatedUrl
|
||
? createMediaResourceNode({
|
||
action: "IMAGE_RESOURCE",
|
||
height: functionKey === "multi-view-nine-grid" ? 347 : 280,
|
||
mediaType: "image",
|
||
mediaUrl: generatedUrl,
|
||
prompt,
|
||
title: payload.slashScene || definition.label || "图片生成",
|
||
width: functionKey === "multi-view-nine-grid" ? 560 : 420,
|
||
x: rightX,
|
||
y: Math.round(anchor.y + anchor.height / 2 - (functionKey === "multi-view-nine-grid" ? 347 : 280) / 2),
|
||
})
|
||
: null;
|
||
const created = generatedNode || createTargetModuleNode("image", rightX, 0, nodes.length + 1);
|
||
const resultNode: CloneNode = {
|
||
...created,
|
||
title: payload.slashScene || definition.label || "图片生成",
|
||
y:
|
||
generatedNode?.y ??
|
||
Math.round(anchor.y + anchor.height / 2 - created.height / 2),
|
||
draftValues: {
|
||
...(created.draftValues || {}),
|
||
generated: Boolean(generatedUrl) || created.draftValues?.generated,
|
||
params: {
|
||
prompt,
|
||
references: payload.references,
|
||
settings: payload.settings,
|
||
slashScene: payload.slashScene,
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
},
|
||
sourceNodeId: anchor.id,
|
||
sourceUrl,
|
||
targetPreset: starterMode || undefined,
|
||
toolbarOption: payload.slashScene || "",
|
||
},
|
||
};
|
||
const referenceEdges = payload.references
|
||
.filter((referenceId) => referenceId !== anchor.id && nodes.some((node) => node.id === referenceId))
|
||
.map((referenceId) => ({ id: makeId("edge"), source: referenceId, target: resultNode.id }));
|
||
|
||
setNodes((current) => [...current, resultNode]);
|
||
setEdges((current) => [
|
||
...current,
|
||
{ id: makeId("edge"), source: anchor.id, target: resultNode.id },
|
||
...referenceEdges,
|
||
]);
|
||
setSelectedNodeId(anchor.id);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
toast.success(generatedUrl ? "已按当前提示词生成图片结果" : "已按当前提示词创建图片生成节点");
|
||
},
|
||
[nodes, videoCanvasFunctionConfig]
|
||
);
|
||
|
||
const runPresetComposerSubmit = useCallback(
|
||
async (nodeId: string, mode: Exclude<ActiveStarterScene, null>, draftPrompt: string) => {
|
||
const node = nodes.find((item) => item.id === nodeId);
|
||
if (!node) return;
|
||
const incomingNodes = edges
|
||
.filter((edge) => edge.target === nodeId)
|
||
.map((edge) => nodes.find((item) => item.id === edge.source))
|
||
.filter((item): item is CloneNode => Boolean(item));
|
||
const imageUrls = incomingNodes
|
||
.filter((item) => item.inputType === "image")
|
||
.map((item) => getNodeSourceMediaUrl(item))
|
||
.filter(Boolean);
|
||
const videoUrls = incomingNodes
|
||
.filter((item) => item.inputType === "video")
|
||
.map((item) => getNodeSourceMediaUrl(item))
|
||
.filter(Boolean);
|
||
|
||
try {
|
||
if (mode === "story-script") {
|
||
const definition = getConfiguredFunction(videoCanvasFunctionConfig, "story-script");
|
||
const textInputs = incomingNodes
|
||
.flatMap((item) => {
|
||
const content = item.draftValues?.content;
|
||
return Array.isArray(content) ? content.filter((value): value is string => typeof value === "string") : [];
|
||
})
|
||
.join("\n\n");
|
||
const prompt = composeFunctionPrompt(definition, `${draftPrompt}\n\n${textInputs}`.trim());
|
||
const response = await workflowAnalysisService.analyze({
|
||
imageUrls,
|
||
mode: "agent-plan",
|
||
nodeTitle: node.title,
|
||
prompt,
|
||
videoUrls,
|
||
});
|
||
if (!response.success) throw new Error(response.error || "故事脚本生成失败");
|
||
const content =
|
||
response.data?.raw ||
|
||
response.data?.agentPlan?.join("\n") ||
|
||
response.data?.summary ||
|
||
"";
|
||
setNodes((current) =>
|
||
current.map((item) =>
|
||
item.id === nodeId
|
||
? {
|
||
...item,
|
||
draftValues: {
|
||
...(item.draftValues || {}),
|
||
content: content ? [content] : item.draftValues?.content,
|
||
generated: true,
|
||
prompt,
|
||
rows: response.data?.shotIdeas || item.draftValues?.rows,
|
||
},
|
||
}
|
||
: item
|
||
)
|
||
);
|
||
toast.success("故事脚本已生成");
|
||
return;
|
||
}
|
||
|
||
const definition = getConfiguredFunction(
|
||
videoCanvasFunctionConfig,
|
||
mode === "audio-to-video" ? "audio-to-video" : "first-frame-video",
|
||
);
|
||
const prompt = composeFunctionPrompt(definition, draftPrompt);
|
||
const response = await videoGenerationService.generateVideo({
|
||
images: imageUrls.slice(0, 4),
|
||
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||
prompt,
|
||
ratio: "16:9",
|
||
resolution: "720p",
|
||
});
|
||
if (!response.success || !response.data?.url) {
|
||
throw new Error(response.error || `${definition.label}生成失败`);
|
||
}
|
||
setNodes((current) =>
|
||
current.map((item) =>
|
||
item.id === nodeId
|
||
? {
|
||
...item,
|
||
kind: "media",
|
||
subtitle: "生成结果",
|
||
draftValues: {
|
||
...(item.draftValues || {}),
|
||
generated: true,
|
||
mediaType: "video",
|
||
mediaUrl: response.data?.url,
|
||
nodeType: "video",
|
||
prompt,
|
||
url: [response.data?.url],
|
||
videoMeta: response.data?.metadata || null,
|
||
},
|
||
}
|
||
: item
|
||
)
|
||
);
|
||
toast.success(`${definition.label}已生成`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "生成失败");
|
||
}
|
||
},
|
||
[edges, nodes, videoCanvasFunctionConfig]
|
||
);
|
||
|
||
const createFreeNode = useCallback(
|
||
(kind: CanvasCreateKind) => {
|
||
if (!canvasCommand) return;
|
||
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
|
||
const node = {
|
||
...created,
|
||
x: Math.round(canvasCommand.canvasX - created.width / 2),
|
||
y: Math.round(canvasCommand.canvasY - created.height / 2),
|
||
};
|
||
addNodes([node], []);
|
||
setCanvasCommand(null);
|
||
setConnectionMenu(null);
|
||
toast.success(`已添加 ${node.title}`);
|
||
},
|
||
[addNodes, canvasCommand, nodes.length]
|
||
);
|
||
|
||
const configureNode = useCallback((nodeId: string) => {
|
||
setSelectedNodeId(nodeId);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
}, []);
|
||
|
||
const copyNode = useCallback(async (nodeId: string) => {
|
||
const node = nodes.find((item) => item.id === nodeId);
|
||
if (!node) return;
|
||
await navigator.clipboard?.writeText(node.title).catch(() => undefined);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
toast.success("节点名称已复制");
|
||
}, [nodes]);
|
||
|
||
const duplicateNode = useCallback((nodeId: string) => {
|
||
const node = nodes.find((item) => item.id === nodeId);
|
||
if (!node) return;
|
||
const duplicate = {
|
||
...node,
|
||
id: makeId("copy"),
|
||
x: node.x + 32,
|
||
y: node.y + 32,
|
||
};
|
||
setNodes((current) => [...current, duplicate]);
|
||
setSelectedNodeId(duplicate.id);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
toast.success("已复制节点");
|
||
}, [nodes]);
|
||
|
||
const runNode = useCallback(async (nodeId: string) => {
|
||
const node = nodes.find((item) => item.id === nodeId);
|
||
if (!node) return;
|
||
setSelectedNodeId(nodeId);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
|
||
const incomingNodes = edges
|
||
.filter((edge) => edge.target === nodeId)
|
||
.map((edge) => nodes.find((item) => item.id === edge.source))
|
||
.filter((item): item is CloneNode => Boolean(item));
|
||
const imageUrls = incomingNodes
|
||
.filter((item) => item.inputType === "image")
|
||
.map((item) => getNodeSourceMediaUrl(item))
|
||
.filter(Boolean);
|
||
const videoUrls = incomingNodes
|
||
.filter((item) => item.inputType === "video")
|
||
.map((item) => getNodeSourceMediaUrl(item))
|
||
.filter(Boolean);
|
||
const action = String(node.draftValues?.action || "");
|
||
const params = (node.draftValues?.params || {}) as Record<string, unknown>;
|
||
const rawPrompt = String(params.prompt || node.draftValues?.prompt || "");
|
||
|
||
try {
|
||
if (node.inputType === "image" || /IMAGE_GENERATE/.test(action)) {
|
||
const functionKey =
|
||
action === "IMAGE_GENERATE_NINE_GRID"
|
||
? "multi-view-nine-grid"
|
||
: node.draftValues?.generatorType === "ENHANCE"
|
||
? "hd-upscale"
|
||
: "image-repaint";
|
||
const definition = getConfiguredFunction(videoCanvasFunctionConfig, functionKey);
|
||
const sourceUrl = imageUrls[0] || getNodeSourceMediaUrl(node);
|
||
const prompt = composeFunctionPrompt(definition, rawPrompt || String(params.option || node.title));
|
||
let generatedUrl = "";
|
||
|
||
if (definition.route === "enhance" && sourceUrl) {
|
||
const response = await imageGenerationService.enhanceImage(sourceUrl);
|
||
generatedUrl = response.success ? response.data?.url || "" : "";
|
||
if (!generatedUrl && !response.success) throw new Error(response.error || "图片增强失败");
|
||
} else {
|
||
const response = await imageGenerationService.generateImage({
|
||
aspectRatio: action === "IMAGE_GENERATE_NINE_GRID" ? "16:9" : undefined,
|
||
imageCount: 1,
|
||
model: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||
prompt,
|
||
referenceImage: sourceUrl || undefined,
|
||
});
|
||
generatedUrl = response.success ? getFirstGeneratedImageUrl(response) : "";
|
||
if (!generatedUrl && !response.success) throw new Error(response.error || "图片生成失败");
|
||
}
|
||
|
||
if (!generatedUrl) throw new Error("图片生成未返回结果");
|
||
setNodes((current) =>
|
||
current.map((item) =>
|
||
item.id === nodeId
|
||
? {
|
||
...item,
|
||
kind: "media",
|
||
subtitle: "生成结果",
|
||
draftValues: {
|
||
...(item.draftValues || {}),
|
||
generated: true,
|
||
mediaType: "image",
|
||
mediaUrl: generatedUrl,
|
||
nodeType: "image",
|
||
prompt,
|
||
sourceUrl,
|
||
url: [generatedUrl],
|
||
},
|
||
}
|
||
: item
|
||
)
|
||
);
|
||
toast.success(`${node.title} 已生成图片结果`);
|
||
return;
|
||
}
|
||
|
||
if (node.inputType === "video" || /VIDEO_GENERATE/.test(action)) {
|
||
const definition = getConfiguredFunction(
|
||
videoCanvasFunctionConfig,
|
||
imageUrls.length >= 2 ? "first-last-frame-video" : imageUrls.length === 1 ? "first-frame-video" : "text-to-video",
|
||
);
|
||
const prompt = composeFunctionPrompt(definition, rawPrompt || node.title);
|
||
const response = await videoGenerationService.generateVideo({
|
||
images: imageUrls.slice(0, 4),
|
||
preferredModelRef: getFunctionModel(videoCanvasFunctionConfig, definition),
|
||
prompt,
|
||
ratio: "16:9",
|
||
resolution: "720p",
|
||
});
|
||
if (!response.success || !response.data?.url) {
|
||
throw new Error(response.error || "视频生成失败");
|
||
}
|
||
setNodes((current) =>
|
||
current.map((item) =>
|
||
item.id === nodeId
|
||
? {
|
||
...item,
|
||
kind: "media",
|
||
subtitle: "生成结果",
|
||
draftValues: {
|
||
...(item.draftValues || {}),
|
||
generated: true,
|
||
mediaType: "video",
|
||
mediaUrl: response.data?.url,
|
||
nodeType: "video",
|
||
prompt,
|
||
url: [response.data?.url],
|
||
videoMeta: response.data?.metadata || null,
|
||
},
|
||
}
|
||
: item
|
||
)
|
||
);
|
||
toast.success(`${node.title} 已生成视频结果`);
|
||
return;
|
||
}
|
||
|
||
if (node.inputType === "text" || /SCRIPT_GENERATE|TEXT_GENERATE/.test(action)) {
|
||
const definition = getConfiguredFunction(
|
||
videoCanvasFunctionConfig,
|
||
imageUrls.length > 0 || videoUrls.length > 0 ? "image-to-prompt" : "story-script",
|
||
);
|
||
const prompt = composeFunctionPrompt(definition, rawPrompt || STORY_SCRIPT_PROMPT);
|
||
const response = await workflowAnalysisService.analyze({
|
||
imageUrls,
|
||
mode: "agent-plan",
|
||
nodeTitle: node.title,
|
||
prompt,
|
||
videoUrls,
|
||
});
|
||
if (!response.success) throw new Error(response.error || "多模态分析失败");
|
||
const content =
|
||
response.data?.raw ||
|
||
response.data?.agentPlan?.join("\n") ||
|
||
response.data?.summary ||
|
||
response.data?.recommendedNextSteps?.join("\n") ||
|
||
"";
|
||
setNodes((current) =>
|
||
current.map((item) =>
|
||
item.id === nodeId
|
||
? {
|
||
...item,
|
||
draftValues: {
|
||
...(item.draftValues || {}),
|
||
content: content ? [content] : item.draftValues?.content,
|
||
generated: true,
|
||
prompt,
|
||
rows: response.data?.shotIdeas || item.draftValues?.rows,
|
||
},
|
||
}
|
||
: item
|
||
)
|
||
);
|
||
toast.success(`${node.title} 已生成文本结果`);
|
||
return;
|
||
}
|
||
|
||
toast.info(`${node.title} 暂无可执行的后端动作`);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : `${node.title} 生成失败`);
|
||
}
|
||
}, [edges, nodes, videoCanvasFunctionConfig]);
|
||
|
||
const deleteNode = useCallback((nodeId: string) => {
|
||
setNodes((current) => current.filter((node) => node.id !== nodeId));
|
||
setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
|
||
setSelectedNodeId((current) => (current === nodeId ? null : current));
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
toast.success("节点已删除");
|
||
}, []);
|
||
|
||
const createStarter = useCallback(
|
||
(starter: (typeof STARTER_CARDS)[number]) => {
|
||
const workflow = createStarterWorkflow(starter.title, PRESET_WORKFLOW_CENTER);
|
||
if (workflow) {
|
||
addNodes(workflow.nodes, workflow.edges);
|
||
setSelectedNodeId(workflow.selectedId);
|
||
setViewport(PRESET_VIEWPORT);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel(null);
|
||
toast.success(`已载入 ${starter.title} 工作流`);
|
||
return;
|
||
}
|
||
|
||
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);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
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.button !== 0) return;
|
||
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
|
||
setSelectedNodeId(null);
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
dragRef.current = {
|
||
kind: "canvas",
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
viewportX: viewport.x,
|
||
viewportY: viewport.y,
|
||
};
|
||
}, [viewport.x, viewport.y]);
|
||
|
||
useEffect(() => {
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
const target = event.target as HTMLElement | null;
|
||
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
|
||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId) {
|
||
event.preventDefault();
|
||
deleteNode(selectedNodeId);
|
||
}
|
||
if (event.key === "Escape") {
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setSelectedNodeId(null);
|
||
setActivePanel(null);
|
||
}
|
||
};
|
||
window.addEventListener("keydown", onKeyDown);
|
||
return () => window.removeEventListener("keydown", onKeyDown);
|
||
}, [deleteNode, selectedNodeId]);
|
||
|
||
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;
|
||
const gridSize = snapEnabled ? 8 : 1;
|
||
const nextX = Math.round((drag.nodeX + dx) / gridSize) * gridSize;
|
||
const nextY = Math.round((drag.nodeY + dy) / gridSize) * gridSize;
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === drag.id
|
||
? { ...node, x: nextX, y: nextY }
|
||
: node
|
||
)
|
||
);
|
||
return;
|
||
}
|
||
setViewport((current) => ({
|
||
...current,
|
||
x: Math.round(drag.viewportX + event.clientX - drag.startX),
|
||
y: Math.round(drag.viewportY + event.clientY - drag.startY),
|
||
}));
|
||
},
|
||
[snapEnabled, 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 zoomAtPoint = useCallback((delta: number, clientX: number, clientY: number) => {
|
||
setViewport((current) => {
|
||
const nextZoom = Math.min(2.4, Math.max(0.3, Number((current.zoom + delta).toFixed(2))));
|
||
if (nextZoom === current.zoom) return current;
|
||
const worldX = (clientX - current.x) / current.zoom;
|
||
const worldY = (clientY - current.y) / current.zoom;
|
||
return {
|
||
x: Math.round(clientX - worldX * nextZoom),
|
||
y: Math.round(clientY - worldY * nextZoom),
|
||
zoom: nextZoom,
|
||
};
|
||
});
|
||
}, []);
|
||
|
||
const fitNodesToView = useCallback(
|
||
(targetNodes: CloneNode[]) => {
|
||
if (targetNodes.length === 0) {
|
||
setViewport(DEFAULT_VIEWPORT);
|
||
return;
|
||
}
|
||
const canvasWidth = canvasRef.current?.clientWidth || window.innerWidth;
|
||
const canvasHeight = canvasRef.current?.clientHeight || window.innerHeight;
|
||
const minX = Math.min(...targetNodes.map((node) => node.x));
|
||
const minY = Math.min(...targetNodes.map((node) => node.y));
|
||
const maxX = Math.max(...targetNodes.map((node) => node.x + node.width));
|
||
const maxY = Math.max(...targetNodes.map((node) => node.y + node.height));
|
||
const boundsWidth = Math.max(1, maxX - minX);
|
||
const boundsHeight = Math.max(1, maxY - minY);
|
||
const padding = targetNodes.length === 1 ? 280 : 220;
|
||
const nextZoom = Math.min(
|
||
1.35,
|
||
Math.max(0.3, Math.min((canvasWidth - padding) / boundsWidth, (canvasHeight - padding) / boundsHeight))
|
||
);
|
||
setViewport({
|
||
x: Math.round((canvasWidth - boundsWidth * nextZoom) / 2 - minX * nextZoom),
|
||
y: Math.round((canvasHeight - boundsHeight * nextZoom) / 2 - minY * nextZoom),
|
||
zoom: Number(nextZoom.toFixed(2)),
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
const focusDockTarget = useCallback(() => {
|
||
const target = selectedNode ? [selectedNode] : nodes;
|
||
fitNodesToView(target);
|
||
setDockOverviewOpen(false);
|
||
}, [fitNodesToView, nodes, selectedNode]);
|
||
|
||
const onCanvasWheel = useCallback(
|
||
(event: ReactWheelEvent<HTMLDivElement>) => {
|
||
const target = event.target as HTMLElement;
|
||
const overlay = target.closest("[data-canvas-overlay]");
|
||
const canvasNode = target.closest("[data-canvas-node]");
|
||
if (overlay && !canvasNode) return;
|
||
event.preventDefault();
|
||
if (event.ctrlKey || event.metaKey) {
|
||
zoomAtPoint(event.deltaY > 0 ? -0.08 : 0.08, event.clientX, event.clientY);
|
||
return;
|
||
}
|
||
setViewport((current) => ({
|
||
...current,
|
||
x: Math.round(current.x - event.deltaX),
|
||
y: Math.round(current.y - event.deltaY),
|
||
}));
|
||
},
|
||
[zoomAtPoint]
|
||
);
|
||
|
||
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 toggleTopPanel = useCallback((panel: Exclude<ActivePanel, null>) => {
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel((current) => (current === panel ? null : panel));
|
||
}, []);
|
||
|
||
const isBusy = isLoading || isProjectLoading;
|
||
const selectedPresetControlNode = isOfficialImageControlNode(selectedNode) ? selectedNode : null;
|
||
const selectedPresetMode = getPresetMode(selectedPresetControlNode);
|
||
const selectedTargetModuleKind = selectedNode ? getTargetModuleKind(selectedNode) : null;
|
||
const selectedTextResourceNode =
|
||
selectedNode && getDraftString(selectedNode, "action") === "TEXT_RESOURCE" ? selectedNode : null;
|
||
const selectedPresetAnchor = useMemo<PresetControlAnchor | null>(() => {
|
||
if (!selectedPresetControlNode) return null;
|
||
return {
|
||
bottomY: Math.round(viewport.y + (selectedPresetControlNode.y + selectedPresetControlNode.height) * viewport.zoom),
|
||
centerX: Math.round(viewport.x + (selectedPresetControlNode.x + selectedPresetControlNode.width / 2) * viewport.zoom),
|
||
topY: Math.round(viewport.y + selectedPresetControlNode.y * viewport.zoom),
|
||
zoom: viewport.zoom,
|
||
};
|
||
}, [selectedPresetControlNode, viewport.x, viewport.y, viewport.zoom]);
|
||
const selectedTargetAnchor = useMemo<PresetControlAnchor | null>(() => {
|
||
if (!selectedNode || !selectedTargetModuleKind) return null;
|
||
return {
|
||
bottomY: Math.round(viewport.y + (selectedNode.y + selectedNode.height) * viewport.zoom),
|
||
centerX: Math.round(viewport.x + (selectedNode.x + selectedNode.width / 2) * viewport.zoom),
|
||
topY: Math.round(viewport.y + selectedNode.y * viewport.zoom),
|
||
zoom: viewport.zoom,
|
||
};
|
||
}, [selectedNode, selectedTargetModuleKind, viewport.x, viewport.y, viewport.zoom]);
|
||
|
||
return (
|
||
<div
|
||
className="relative h-dvh w-full overflow-hidden bg-[#151515] text-white"
|
||
onContextMenu={openCanvasCommand}
|
||
onDoubleClick={openCanvasCommand}
|
||
onPointerDown={startCanvasDrag}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
onWheel={onCanvasWheel}
|
||
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 origin-top-left"
|
||
style={{ height: CANVAS_EXTENT, transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, width: CANVAS_EXTENT }}
|
||
>
|
||
<CloneEdges edges={edges} nodes={nodes} />
|
||
{nodes.map((node) => (
|
||
<CloneCanvasNode
|
||
key={node.id}
|
||
node={node}
|
||
selected={node.id === selectedNodeId}
|
||
onContextMenu={(event) => openNodeContext(event, node)}
|
||
onModuleAction={(actionLabel) => runTargetModuleAction(node.id, actionLabel)}
|
||
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
|
||
onPointerDown={(event) => startNodeDrag(event, node)}
|
||
onTextResourceChange={(content) => updateTextResourceContent(node.id, content)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{nodes.length === 0 && !activePanel ? (
|
||
<div
|
||
className="-translate-x-1/2 -translate-y-1/2 absolute left-1/2 top-[51%] z-20 w-[900px] max-w-[calc(100vw-180px)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<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}
|
||
|
||
{canvasCommand ? (
|
||
<CanvasCommandPalette
|
||
state={canvasCommand}
|
||
onClose={() => setCanvasCommand(null)}
|
||
onCreate={createFreeNode}
|
||
onGallerySelect={() => {
|
||
setActivePanel("assets");
|
||
setCanvasCommand(null);
|
||
}}
|
||
onUploadFiles={addCanvasCommandUploadedFiles}
|
||
/>
|
||
) : null}
|
||
|
||
{nodeContextMenu && contextNode ? (
|
||
<NodeContextMenu
|
||
node={contextNode}
|
||
state={nodeContextMenu}
|
||
onClose={() => setNodeContextMenu(null)}
|
||
onConfigure={() => configureNode(contextNode.id)}
|
||
onCopy={() => void copyNode(contextNode.id)}
|
||
onDelete={() => deleteNode(contextNode.id)}
|
||
onDuplicate={() => duplicateNode(contextNode.id)}
|
||
onRun={() => runNode(contextNode.id)}
|
||
/>
|
||
) : null}
|
||
|
||
{connectionMenu && connectionNode ? (
|
||
<ConnectionAddMenu
|
||
node={connectionNode}
|
||
onClose={() => setConnectionMenu(null)}
|
||
onCreate={addConnectedModule}
|
||
state={connectionMenu}
|
||
/>
|
||
) : null}
|
||
|
||
{selectedPresetAnchor && selectedPresetControlNode ? (
|
||
<PresetWorkflowControls
|
||
anchor={selectedPresetAnchor}
|
||
mode={selectedPresetMode ?? "character-three-view"}
|
||
nodes={nodes}
|
||
onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)}
|
||
onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)}
|
||
onPresetSubmit={(prompt) =>
|
||
runPresetComposerSubmit(
|
||
selectedPresetControlNode.id,
|
||
selectedPresetMode ?? "character-three-view",
|
||
prompt
|
||
)
|
||
}
|
||
selectedNode={selectedPresetControlNode}
|
||
/>
|
||
) : null}
|
||
|
||
{selectedNode && selectedTargetModuleKind === "text" && selectedTargetAnchor ? (
|
||
<TextTargetModuleComposer
|
||
anchor={selectedTargetAnchor}
|
||
node={selectedNode}
|
||
onSubmit={(prompt) => {
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === selectedNode.id
|
||
? {
|
||
...node,
|
||
draftValues: {
|
||
...(node.draftValues || {}),
|
||
content: prompt ? [prompt] : [],
|
||
manualEditing: true,
|
||
manualTextComposer: true,
|
||
params: {
|
||
...((node.draftValues?.params as Record<string, unknown> | undefined) || {}),
|
||
prompt,
|
||
},
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
);
|
||
toast.success("文本内容已写入当前模块");
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
<TopBar
|
||
draftTitle={draftTitle}
|
||
isTitleEditing={isTitleEditing}
|
||
onAccount={() => toggleTopPanel("account")}
|
||
onCommitTitle={commitTitle}
|
||
onDraftTitleChange={setDraftTitle}
|
||
onStartTitleEdit={() => setIsTitleEditing(true)}
|
||
projectTitle={projectTitle}
|
||
routerBack={() => router.push("/")}
|
||
siteLogo={siteBrandSettings?.siteLogo}
|
||
siteLogoDark={siteBrandSettings?.siteLogoDark}
|
||
siteName={siteBrandSettings?.siteName}
|
||
/>
|
||
|
||
<LeftToolbar
|
||
activePanel={activePanel}
|
||
onOpen={(panel) => {
|
||
setCanvasCommand(null);
|
||
setNodeContextMenu(null);
|
||
setConnectionMenu(null);
|
||
setActivePanel((current) => current === panel ? null : panel);
|
||
}}
|
||
/>
|
||
|
||
<ZoomDock
|
||
compact={false}
|
||
nodes={nodes}
|
||
overviewOpen={dockOverviewOpen}
|
||
selectedNodeId={selectedNodeId}
|
||
snapEnabled={snapEnabled}
|
||
zoom={viewport.zoom}
|
||
onCenter={focusDockTarget}
|
||
onToggleOverview={() => setDockOverviewOpen((value) => !value)}
|
||
onToggleSnap={() => setSnapEnabled((value) => !value)}
|
||
onZoomIn={() => zoomBy(0.1)}
|
||
onZoomOut={() => zoomBy(-0.1)}
|
||
/>
|
||
|
||
{activePanel === "add" ? (
|
||
<AddModulePanel
|
||
onAddModule={addTargetModule}
|
||
onClose={() => setActivePanel(null)}
|
||
onGallerySelect={() => setActivePanel("assets")}
|
||
onUploadFiles={addUploadedFiles}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "workflow" ? (
|
||
<WorkflowPanel
|
||
workflows={capturedWorkflows}
|
||
onAdd={addWorkflowPreset}
|
||
onClose={() => setActivePanel(null)}
|
||
/>
|
||
) : null}
|
||
|
||
{selectedNode && !selectedPresetControlNode && !selectedTargetModuleKind && !selectedTextResourceNode ? (
|
||
<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" ? (
|
||
<SkillsAccessModal onClose={() => setActivePanel(null)} />
|
||
) : null}
|
||
|
||
{activePanel === "share" ? (
|
||
<SharePopover
|
||
onClose={() => setActivePanel(null)}
|
||
onCopyLink={() => {
|
||
void copyShareLink();
|
||
setActivePanel(null);
|
||
}}
|
||
onPublish={() => setActivePanel("publish")}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "publish" ? (
|
||
<PublishModal
|
||
projectTitle={projectTitle}
|
||
publishPublic={publishPublic}
|
||
onClose={() => setActivePanel(null)}
|
||
onTogglePublic={() => setPublishPublic((value) => !value)}
|
||
/>
|
||
) : null}
|
||
|
||
{activePanel === "member" ? (
|
||
<MemberStoreModal onClose={() => setActivePanel(null)} />
|
||
) : null}
|
||
|
||
{activePanel === "notifications" ? (
|
||
<NotificationsPopover onClose={() => setActivePanel(null)} />
|
||
) : null}
|
||
|
||
{activePanel === "account" ? (
|
||
<AccountPopover onClose={() => setActivePanel(null)} />
|
||
) : null}
|
||
|
||
{previewNode ? (
|
||
<LibModal onClose={() => setPreviewNode(null)} title={previewNode.title || "预览"} width={920}>
|
||
<ImageNodePreview node={previewNode} />
|
||
</LibModal>
|
||
) : null}
|
||
|
||
{angleEditorNode ? (
|
||
<MultiAngleEditorDialog
|
||
node={angleEditorNode}
|
||
onApply={async (settings) => {
|
||
const angleDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "angle-edit");
|
||
const sourceUrl = getNodeMediaUrl(angleEditorNode) || getNodeSourceMediaUrl(angleEditorNode);
|
||
let generatedUrl = "";
|
||
if (sourceUrl && videoCanvasFunctionConfig.enabled && angleDefinition?.enabled) {
|
||
const response = await imageGenerationService.editAngle({
|
||
horizontalAngle: settings.horizontalAngle,
|
||
imageUrl: sourceUrl,
|
||
verticalAngle: settings.verticalAngle,
|
||
zoom: settings.zoom,
|
||
});
|
||
generatedUrl = response.success ? response.data?.url || response.data?.thumbnailUrl || "" : "";
|
||
if (!generatedUrl && !response.success) {
|
||
toast.error(response.error || "多角度生成失败,已保留参数");
|
||
}
|
||
}
|
||
setNodes((current) =>
|
||
current.map((node) =>
|
||
node.id === angleEditorNode.id
|
||
? {
|
||
...node,
|
||
draftValues: {
|
||
...(node.draftValues || {}),
|
||
angleSettings: settings,
|
||
params: {
|
||
...((node.draftValues?.params as Record<string, unknown> | undefined) || {}),
|
||
horizontal_angle: settings.horizontalAngle,
|
||
prompt: settings.promptEnabled ? "按自定义多角度参数生成视角变化" : "",
|
||
vertical_angle: settings.verticalAngle,
|
||
zoom: settings.zoom,
|
||
},
|
||
},
|
||
}
|
||
: node
|
||
)
|
||
);
|
||
if (generatedUrl) {
|
||
const resultNode = createMediaResourceNode({
|
||
action: "IMAGE_RESOURCE",
|
||
height: 280,
|
||
mediaType: "image",
|
||
mediaUrl: generatedUrl,
|
||
prompt: angleDefinition.prompt,
|
||
title: "多角度结果",
|
||
width: 420,
|
||
x: Math.round(angleEditorNode.x + angleEditorNode.width + OFFICIAL_NODE_GAP),
|
||
y: Math.round(angleEditorNode.y + angleEditorNode.height / 2 - 140),
|
||
});
|
||
setNodes((current) => [
|
||
...current,
|
||
{
|
||
...resultNode,
|
||
draftValues: {
|
||
...(resultNode.draftValues || {}),
|
||
angleSettings: settings,
|
||
generated: true,
|
||
sourceNodeId: angleEditorNode.id,
|
||
sourceUrl,
|
||
},
|
||
},
|
||
]);
|
||
setEdges((current) => [
|
||
...current,
|
||
{ id: makeId("edge"), source: angleEditorNode.id, target: resultNode.id },
|
||
]);
|
||
setSelectedNodeId(resultNode.id);
|
||
}
|
||
setAngleEditorNodeId(null);
|
||
toast.success(generatedUrl ? "已按多角度参数生成结果" : "已应用多角度参数");
|
||
}}
|
||
onClose={() => setAngleEditorNodeId(null)}
|
||
/>
|
||
) : 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,
|
||
isTitleEditing,
|
||
onAccount,
|
||
onCommitTitle,
|
||
onDraftTitleChange,
|
||
onStartTitleEdit,
|
||
projectTitle,
|
||
routerBack,
|
||
siteLogo,
|
||
siteLogoDark,
|
||
siteName,
|
||
}: {
|
||
draftTitle: string;
|
||
isTitleEditing: boolean;
|
||
onAccount: () => void;
|
||
onCommitTitle: () => void;
|
||
onDraftTitleChange: (value: string) => void;
|
||
onStartTitleEdit: () => void;
|
||
projectTitle: string;
|
||
routerBack: () => void;
|
||
siteLogo?: string | null;
|
||
siteLogoDark?: string | null;
|
||
siteName?: string | null;
|
||
}) {
|
||
const { data: membershipSummary } = useSWR<MembershipSummaryResponse>("/api/user/membership-summary", fetcher);
|
||
const credits = Math.max(0, Number(membershipSummary?.summary?.currentCredits ?? 0));
|
||
|
||
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"
|
||
>
|
||
<StylizedBrandLogo siteLogo={siteLogo} siteLogoDark={siteLogoDark} siteName={siteName} />
|
||
</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">
|
||
<button
|
||
className="flex h-10 cursor-pointer items-center gap-2 rounded-xl border border-white/8 bg-[#202020]/95 px-3 text-[13px] text-white/78 shadow-[0_12px_30px_rgba(0,0,0,0.28)] transition hover:bg-[#2a2a2a] hover:text-white"
|
||
onClick={onAccount}
|
||
title="账户"
|
||
type="button"
|
||
>
|
||
<span className="flex items-center gap-1.5 text-white/74">
|
||
<span className="text-[#e3c55d]">⚡</span>
|
||
<span className="font-medium tabular-nums">{formatCreditValue(credits)}</span>
|
||
</span>
|
||
<span className="flex h-7 w-7 items-center justify-center overflow-hidden rounded-[9px] bg-gradient-to-br from-[#4e65f0] to-[#1d244b] text-white shadow-[inset_0_0_12px_rgba(255,255,255,0.2)]">
|
||
<UserRound className="h-4 w-4" />
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function formatCreditValue(value: number) {
|
||
if (!Number.isFinite(value)) return "0";
|
||
return Math.round(value).toLocaleString("zh-CN");
|
||
}
|
||
|
||
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,
|
||
nodes = [],
|
||
onCenter,
|
||
onToggleOverview,
|
||
onToggleSnap,
|
||
overviewOpen = false,
|
||
selectedNodeId,
|
||
snapEnabled = true,
|
||
onZoomIn,
|
||
onZoomOut,
|
||
zoom,
|
||
}: {
|
||
compact?: boolean;
|
||
nodes?: CloneNode[];
|
||
onCenter: () => void;
|
||
onToggleOverview?: () => void;
|
||
onToggleSnap?: () => void;
|
||
overviewOpen?: boolean;
|
||
selectedNodeId?: string | null;
|
||
snapEnabled?: boolean;
|
||
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-5 left-5 z-40" data-canvas-overlay="true">
|
||
<div style={{ transform: `scale(${OFFICIAL_ZOOM_DOCK_SCALE})`, transformOrigin: "bottom left" }}>
|
||
{overviewOpen ? (
|
||
<CanvasOverviewPopover nodes={nodes} selectedNodeId={selectedNodeId || null} />
|
||
) : null}
|
||
<div className="flex h-[54px] items-center gap-1 rounded-[14px] border border-white/9 bg-[#202020]/95 px-2 text-white/74 shadow-[0_18px_48px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
|
||
<ZoomDockButton active={overviewOpen} label="画布总览" onClick={onToggleOverview}>
|
||
<GridTilesGlyph />
|
||
</ZoomDockButton>
|
||
<ZoomDockButton active={Boolean(selectedNodeId)} label="定位选中" onClick={onCenter} selectedOutline>
|
||
<MapPinGlyph />
|
||
</ZoomDockButton>
|
||
<ZoomDockButton active={snapEnabled} label={snapEnabled ? "吸附已开启" : "吸附已关闭"} onClick={onToggleSnap}>
|
||
<MagnetGlyph />
|
||
</ZoomDockButton>
|
||
<div className="mx-1 h-7 w-px bg-white/10" />
|
||
<button
|
||
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-[10px] text-[21px] leading-none text-white/72 transition-colors hover:bg-white/8 hover:text-white"
|
||
onClick={onZoomOut}
|
||
type="button"
|
||
>
|
||
-
|
||
</button>
|
||
<button
|
||
className="h-10 min-w-[68px] cursor-pointer rounded-[10px] px-2 text-center text-[16px] font-medium tabular-nums leading-[40px] text-white/86 transition-colors hover:bg-white/8"
|
||
onClick={onCenter}
|
||
type="button"
|
||
>
|
||
{Math.round(zoom * 100)}%
|
||
</button>
|
||
<button
|
||
className="flex h-10 w-10 cursor-pointer items-center justify-center rounded-[10px] text-[23px] leading-none text-white/82 transition-colors hover:bg-white/8 hover:text-white"
|
||
onClick={onZoomIn}
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ZoomDockButton({
|
||
active,
|
||
children,
|
||
label,
|
||
onClick,
|
||
selectedOutline,
|
||
}: {
|
||
active?: boolean;
|
||
children: ReactNode;
|
||
label: string;
|
||
onClick?: () => void;
|
||
selectedOutline?: boolean;
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex h-10 w-10 cursor-pointer items-center justify-center rounded-[10px] text-white/74 transition-colors hover:bg-white/10 hover:text-white",
|
||
active && "bg-white/10 text-white",
|
||
active && selectedOutline && "bg-transparent shadow-[0_0_0_2px_#8dc5ff]"
|
||
)}
|
||
onClick={onClick}
|
||
title={label}
|
||
type="button"
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function CanvasOverviewPopover({ nodes, selectedNodeId }: { nodes: CloneNode[]; selectedNodeId: string | null }) {
|
||
const bounds = useMemo(() => {
|
||
if (nodes.length === 0) return null;
|
||
const minX = Math.min(...nodes.map((node) => node.x));
|
||
const minY = Math.min(...nodes.map((node) => node.y));
|
||
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
|
||
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
|
||
return { minX, minY, maxX, maxY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) };
|
||
}, [nodes]);
|
||
const scale = bounds ? Math.min(188 / bounds.width, 112 / bounds.height) : 1;
|
||
|
||
return (
|
||
<div className="mb-3 w-[232px] rounded-[16px] border border-white/10 bg-[#202020]/96 p-3 shadow-[0_20px_60px_rgba(0,0,0,0.42)] backdrop-blur-[12px]">
|
||
<div className="mb-2 flex items-center justify-between text-[12px] text-white/52">
|
||
<span>画布总览</span>
|
||
<span>{nodes.length} 个节点</span>
|
||
</div>
|
||
<div className="relative h-[124px] overflow-hidden rounded-[10px] border border-white/8 bg-[#111]">
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.2)_1px,transparent_1px)] bg-[length:12px_12px] opacity-25" />
|
||
{bounds ? (
|
||
nodes.map((node) => (
|
||
<span
|
||
className={cn(
|
||
"absolute rounded-[3px] border border-white/18 bg-white/18",
|
||
node.id === selectedNodeId && "border-[#8dc5ff] bg-[#8dc5ff]/42"
|
||
)}
|
||
key={node.id}
|
||
style={{
|
||
height: Math.max(5, node.height * scale),
|
||
left: 14 + (node.x - bounds.minX) * scale,
|
||
top: 8 + (node.y - bounds.minY) * scale,
|
||
width: Math.max(8, node.width * scale),
|
||
}}
|
||
/>
|
||
))
|
||
) : (
|
||
<div className="absolute inset-0 flex items-center justify-center text-[12px] text-white/34">暂无节点</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StylizedBrandLogo({
|
||
siteLogo,
|
||
siteLogoDark,
|
||
siteName,
|
||
}: {
|
||
siteLogo?: string | null;
|
||
siteLogoDark?: string | null;
|
||
siteName?: string | null;
|
||
}) {
|
||
const resolvedSiteName = String(siteName || "AI Chatbot").trim() || "AI Chatbot";
|
||
const displayLogo = siteLogoDark?.trim() || siteLogo?.trim() || "";
|
||
const shouldInvertLogo = !siteLogoDark?.trim() && Boolean(siteLogo?.trim());
|
||
|
||
return (
|
||
<span className="flex min-w-0 items-center gap-2.5">
|
||
{displayLogo ? (
|
||
<img
|
||
alt={resolvedSiteName}
|
||
className={cn(
|
||
"h-8 w-8 shrink-0 object-contain",
|
||
shouldInvertLogo && "brightness-0 invert"
|
||
)}
|
||
src={displayLogo}
|
||
/>
|
||
) : (
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/10">
|
||
<Wand2 className="h-4 w-4 text-white" />
|
||
</span>
|
||
)}
|
||
<span className="max-w-[140px] truncate text-base font-semibold text-white">
|
||
{resolvedSiteName}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 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 PresetWorkflowControls({
|
||
anchor,
|
||
mode,
|
||
nodes,
|
||
onComposerSubmit,
|
||
onImageToolAction,
|
||
onPresetSubmit,
|
||
selectedNode,
|
||
}: {
|
||
anchor: PresetControlAnchor;
|
||
mode: Exclude<ActiveStarterScene, null>;
|
||
nodes: CloneNode[];
|
||
onComposerSubmit: (payload: ComposerSubmitPayload) => void;
|
||
onImageToolAction: (action: ImageToolbarAction, option?: string) => void;
|
||
onPresetSubmit: (prompt: string) => void;
|
||
selectedNode: CloneNode;
|
||
}) {
|
||
if (mode === "character-three-view") {
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||
<CharacterPresetToolbar anchor={anchor} onToolAction={onImageToolAction} />
|
||
<PromptComposer
|
||
anchor={anchor}
|
||
nodes={nodes}
|
||
onSubmit={onComposerSubmit}
|
||
referenceThumbnailUrl={getNodeMediaUrl(selectedNode) || CHARACTER_IMAGE_URL}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (mode === "story-script") {
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||
<PresetFloatingToolbar
|
||
anchor={anchor}
|
||
items={[
|
||
["拆分场景", "按镜号拆分脚本接口待接入。"],
|
||
["生成分镜", "分镜生成接口待接入。"],
|
||
["表格视图", "脚本表格视图接口待接入。"],
|
||
["导出", "脚本导出接口待接入。"],
|
||
]}
|
||
/>
|
||
<PresetComposerPanel
|
||
anchor={anchor}
|
||
credit="8"
|
||
onSubmit={onPresetSubmit}
|
||
prompt={STORY_SCRIPT_PROMPT}
|
||
submitMessage="故事脚本生成接口待接入。"
|
||
title="脚本生成器"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (mode === "first-frame") {
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||
<PresetFloatingToolbar
|
||
anchor={anchor}
|
||
items={[
|
||
["高清", "首帧高清接口待接入。"],
|
||
["扩图", "首帧扩图接口待接入。"],
|
||
["替换背景", "背景替换接口待接入。"],
|
||
["下载", "图片下载接口待接入。"],
|
||
]}
|
||
/>
|
||
<PresetComposerPanel
|
||
anchor={anchor}
|
||
credit="28"
|
||
onSubmit={onPresetSubmit}
|
||
prompt="以当前图为首帧生成视频。"
|
||
submitMessage="首帧图生视频接口待接入。"
|
||
thumbnailUrl={FIRST_FRAME_IMAGE_URL}
|
||
title="首帧图生视频"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
|
||
<AudioToVideoComposerPanel anchor={anchor} onSubmit={onPresetSubmit} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TextTargetModuleComposer({
|
||
anchor,
|
||
node,
|
||
onSubmit,
|
||
}: {
|
||
anchor: PresetControlAnchor;
|
||
node: CloneNode;
|
||
onSubmit: (prompt: string) => void;
|
||
}) {
|
||
const contentValues = node.draftValues?.content;
|
||
const initialPrompt =
|
||
Array.isArray(contentValues) && typeof contentValues[0] === "string" ? contentValues[0] : "";
|
||
const [prompt, setPrompt] = useState(initialPrompt);
|
||
|
||
useEffect(() => {
|
||
setPrompt(initialPrompt);
|
||
}, [initialPrompt, node.id]);
|
||
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute z-30 overflow-visible"
|
||
data-canvas-overlay="true"
|
||
style={{
|
||
height: TEXT_MODULE_COMPOSER_HEIGHT,
|
||
left: anchor.centerX,
|
||
top: anchor.bottomY + 24,
|
||
transform: "translateX(-50%)",
|
||
width: TEXT_MODULE_COMPOSER_WIDTH,
|
||
}}
|
||
>
|
||
<div className="relative h-[270px] w-[1274px] rounded-[16px] border border-white/8 bg-[#262626]/98 p-6 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||
<textarea
|
||
className="h-[148px] w-full resize-none bg-transparent text-[24px] leading-[34px] text-white/74 outline-none placeholder:text-white/40"
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
placeholder="写下你想讲的故事、场景或角色设定。例如:一个来自未来的机器人,在城市屋顶看星星。"
|
||
value={prompt}
|
||
/>
|
||
<div className="absolute bottom-5 left-6 right-[86px] flex items-center gap-8 overflow-hidden text-[24px] text-white/82">
|
||
<ComposerControl
|
||
label="GVLM 3.1"
|
||
icon="⌘"
|
||
dropdown
|
||
onClick={() => toast.info("文本模型选择后端待接入。")}
|
||
/>
|
||
<div className="min-w-0 flex-1" />
|
||
<ComposerControl label="文" onClick={() => toast.info("翻译接口待接入。")} />
|
||
<ComposerControl label="6" icon="⚡" muted onClick={() => toast.info("积分估算接入本地积分系统。")} />
|
||
</div>
|
||
<button
|
||
className="absolute bottom-5 right-5 flex h-14 w-14 items-center justify-center rounded-[14px] bg-white/76 text-[34px] leading-none text-black transition hover:bg-white"
|
||
onClick={() => onSubmit(prompt)}
|
||
type="button"
|
||
>
|
||
↑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CharacterPresetToolbar({
|
||
anchor,
|
||
onToolAction,
|
||
}: {
|
||
anchor?: PresetControlAnchor;
|
||
onToolAction: (action: ImageToolbarAction, option?: string) => void;
|
||
}) {
|
||
const [toolbarMenu, setToolbarMenu] = useState<SceneToolbarMenu>(null);
|
||
const iconActions: Array<{ action: Extract<ImageToolbarAction, "annotate" | "rotate" | "download" | "preview">; label: string }> = [
|
||
{ action: "annotate", label: "标注" },
|
||
{ action: "rotate", label: "旋转" },
|
||
{ action: "download", label: "下载" },
|
||
{ action: "preview", label: "预览" },
|
||
];
|
||
const activeToolbarMenu = toolbarMenu;
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
className="pointer-events-auto absolute flex h-[58px] items-center gap-5 rounded-[14px] border border-white/8 bg-[#262626]/96 px-5 text-[16px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]"
|
||
style={
|
||
anchor
|
||
? {
|
||
left: anchor.centerX,
|
||
top: anchor.topY - (24 * anchor.zoom + 10),
|
||
transform: `translateX(-50%) translateY(-100%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
|
||
transformOrigin: "bottom center",
|
||
}
|
||
: {
|
||
left: "50%",
|
||
top: 168,
|
||
transform: `translateX(-50%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
|
||
transformOrigin: "top center",
|
||
}
|
||
}
|
||
>
|
||
<SceneTool active={toolbarMenu === "panorama"} kind="panorama" label="全景" badge="NEW" onClick={() => setToolbarMenu((current) => current === "panorama" ? null : "panorama")} />
|
||
<SceneTool active={false} kind="orbit" label="多角度" onClick={() => {
|
||
setToolbarMenu(null);
|
||
onToolAction("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" />
|
||
{iconActions.map((item) => (
|
||
<button
|
||
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-white/84 hover:bg-white/8"
|
||
key={item.action}
|
||
onClick={() => onToolAction(item.action)}
|
||
title={item.label}
|
||
type="button"
|
||
>
|
||
<ImageToolbarActionGlyph action={item.action} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
{activeToolbarMenu ? (
|
||
<SceneToolbarPopover
|
||
anchor={anchor}
|
||
menu={activeToolbarMenu}
|
||
onClose={() => setToolbarMenu(null)}
|
||
onSelect={(option) => {
|
||
onToolAction(activeToolbarMenu, option);
|
||
setToolbarMenu(null);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AudioToVideoComposerPanel({
|
||
anchor,
|
||
onSubmit,
|
||
}: {
|
||
anchor?: PresetControlAnchor;
|
||
onSubmit?: (prompt: string) => void;
|
||
}) {
|
||
const [prompt, setPrompt] = useState(AUDIO_TO_VIDEO_PROMPT);
|
||
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute overflow-visible"
|
||
style={
|
||
anchor
|
||
? {
|
||
height: 260,
|
||
left: anchor.centerX,
|
||
top: anchor.bottomY + 18,
|
||
transform: "translateX(-50%)",
|
||
width: 1216,
|
||
}
|
||
: {
|
||
bottom: 58,
|
||
height: 260,
|
||
left: "50%",
|
||
transform: "translateX(-50%)",
|
||
width: 1216,
|
||
}
|
||
}
|
||
>
|
||
<div className="relative h-[260px] w-[1216px] rounded-[16px] border border-white/8 bg-[#252525]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||
<button className="absolute right-6 top-5 text-[32px] text-white/38 hover:text-white/70" onClick={() => toast.info("展开音频生视频编辑器待接入。")} type="button">↗</button>
|
||
<div className="flex items-center gap-3">
|
||
{["文生视频", "全能参考", "图生视频", "首尾帧", "图片参考"].map((tab, index) => (
|
||
<button
|
||
className={cn(
|
||
"h-[38px] rounded-[8px] border border-white/10 px-4 text-[18px] text-white/48",
|
||
index === 1 && "bg-white/8 text-white"
|
||
)}
|
||
key={tab}
|
||
onClick={() => toast.info(`${tab}模式后端待接入。`)}
|
||
type="button"
|
||
>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-4 flex items-center gap-3">
|
||
{[
|
||
["标记", "◎"],
|
||
["运镜", "▣"],
|
||
["角色库", "♙"],
|
||
].map(([label, icon]) => (
|
||
<button
|
||
className="flex h-[72px] w-[92px] flex-col items-center justify-center gap-1 rounded-[8px] border border-white/10 text-[16px] text-white/50 transition hover:bg-white/[0.07] hover:text-white/80"
|
||
key={label}
|
||
onClick={() => toast.info(`${label}参数后端待接入。`)}
|
||
type="button"
|
||
>
|
||
<span className="text-[26px] leading-none">{icon}</span>
|
||
<span>{label}</span>
|
||
</button>
|
||
))}
|
||
<div className="relative h-[72px] w-[86px] overflow-hidden rounded-[9px]">
|
||
<img alt="图片参考" className="h-full w-full object-cover" src={AUDIO_REFERENCE_IMAGE_URL} />
|
||
<span className="absolute right-1 top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-black/60 px-1 text-[12px]">1</span>
|
||
</div>
|
||
<div className="relative flex h-[72px] w-[86px] items-center justify-center rounded-[9px] bg-white/12">
|
||
<Music2 className="h-9 w-9 text-white" />
|
||
<span className="absolute mt-[-44px] ml-[52px] flex h-5 min-w-5 items-center justify-center rounded-full bg-black/60 px-1 text-[12px]">2</span>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
className="mt-5 h-[62px] w-full resize-none bg-transparent text-[24px] leading-8 text-white/78 outline-none placeholder:text-white/46"
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
placeholder="根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。"
|
||
value={prompt}
|
||
/>
|
||
<div className="absolute bottom-5 left-5 right-[86px] flex items-center gap-7 overflow-hidden text-[22px] text-white/82">
|
||
<ComposerControl label="Seedance 2.0" icon="▥" dropdown onClick={() => toast.info("视频模型选择后端待接入。")} />
|
||
<span className="rounded-[5px] bg-[#f6c85f] px-1.5 py-0.5 text-[13px] font-bold text-[#2b2314]">VIP</span>
|
||
<ComposerControl label="16:9" icon="▭" onClick={() => toast.info("比例选择后端待接入。")} />
|
||
<ComposerControl label="720P" onClick={() => toast.info("清晰度选择后端待接入。")} />
|
||
<ComposerControl label="5s" onClick={() => toast.info("时长选择后端待接入。")} />
|
||
<ComposerControl label="" icon="♬" dropdown onClick={() => toast.info("音频参数后端待接入。")} />
|
||
<ComposerControl label="文" onClick={() => toast.info("翻译接口待接入。")} />
|
||
<ComposerControl label="" icon="⌘" onClick={() => toast.info("高级设置后端待接入。")} />
|
||
<ComposerControl label="1个" dropdown onClick={() => toast.info("生成数量后端待接入。")} />
|
||
<ComposerControl label="108 / 135" icon="⚡" muted onClick={() => toast.info("积分估算接入本地积分系统。")} />
|
||
</div>
|
||
<button
|
||
className="absolute bottom-5 right-5 flex h-14 w-14 items-center justify-center rounded-[14px] bg-white text-[34px] leading-none text-black hover:bg-white/90"
|
||
onClick={() => {
|
||
if (onSubmit) {
|
||
onSubmit(prompt);
|
||
return;
|
||
}
|
||
toast.info("音频生视频生成接口待接入。");
|
||
}}
|
||
type="button"
|
||
>
|
||
↑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MultiAngleEditorDialog({
|
||
node,
|
||
onApply,
|
||
onClose,
|
||
}: {
|
||
node: CloneNode;
|
||
onApply: (settings: AngleEditorSettings) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const initialSettings = (node.draftValues?.angleSettings || {}) as Partial<AngleEditorSettings>;
|
||
const [activeTab, setActiveTab] = useState("自定义");
|
||
const [horizontalAngle, setHorizontalAngle] = useState(Number(initialSettings.horizontalAngle ?? 0));
|
||
const [verticalAngle, setVerticalAngle] = useState(Number(initialSettings.verticalAngle ?? 0));
|
||
const [zoom, setZoom] = useState(Number(initialSettings.zoom ?? 50));
|
||
const [promptEnabled, setPromptEnabled] = useState(Boolean(initialSettings.promptEnabled));
|
||
const sourceUrl = getNodeMediaUrl(node) || getNodeSourceMediaUrl(node) || CHARACTER_THREE_VIEW_URL;
|
||
const tabs = ["自定义", "鱼眼视角", "倾斜视角", "正面俯拍", "正面仰拍", "全景俯拍", "背面视角"];
|
||
|
||
const reset = () => {
|
||
setActiveTab("自定义");
|
||
setHorizontalAngle(0);
|
||
setVerticalAngle(0);
|
||
setZoom(50);
|
||
setPromptEnabled(false);
|
||
};
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[130] flex items-center justify-center bg-black/30" data-canvas-overlay="true">
|
||
<div className="relative h-[768px] w-[1200px] overflow-hidden rounded-[18px] border border-white/10 bg-[#202020] px-8 py-8 text-white shadow-[0_28px_90px_rgba(0,0,0,0.58)]">
|
||
<div className="flex items-start justify-between">
|
||
<h2 className="text-[30px] font-semibold tracking-[0] text-white">多角度编辑器</h2>
|
||
<button
|
||
className="-mr-2 -mt-2 flex h-12 w-12 cursor-pointer items-center justify-center rounded-[8px] text-white/50 transition hover:bg-white/8 hover:text-white"
|
||
onClick={onClose}
|
||
type="button"
|
||
>
|
||
<X className="h-9 w-9" strokeWidth={1.7} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-8 flex gap-4">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
className={cn(
|
||
"h-14 min-w-[148px] cursor-pointer rounded-[10px] border border-white/8 px-6 text-[21px] font-medium text-white/45 transition hover:bg-white/8 hover:text-white/80",
|
||
activeTab === tab && "border-transparent bg-white/14 text-white"
|
||
)}
|
||
key={tab}
|
||
onClick={() => setActiveTab(tab)}
|
||
type="button"
|
||
>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-6 flex gap-8">
|
||
<div className="relative flex h-[480px] w-[480px] shrink-0 items-center justify-center overflow-hidden rounded-[18px] bg-[#323232]">
|
||
<div className="absolute h-[300px] w-[300px] rounded-full border border-white/24" />
|
||
<div className="absolute h-[300px] w-[300px] rounded-full border border-white/14 [clip-path:ellipse(34%_50%_at_50%_50%)]" />
|
||
<div className="absolute h-[300px] w-[300px] rounded-full border border-white/14 [clip-path:ellipse(50%_22%_at_50%_50%)]" />
|
||
<div className="absolute h-[240px] w-[300px] rounded-full border border-white/14" />
|
||
<div className="absolute h-[165px] w-[300px] rounded-full border border-white/12" />
|
||
<div className="absolute h-px w-[308px] bg-white/16" />
|
||
<div className="absolute h-[308px] w-px bg-white/16" />
|
||
<button className="absolute top-10 text-[32px] text-white/42" onClick={() => setVerticalAngle((value) => Math.min(45, value + 5))} type="button">⌃</button>
|
||
<button className="absolute bottom-9 text-[32px] text-white/42" onClick={() => setVerticalAngle((value) => Math.max(-45, value - 5))} type="button">⌄</button>
|
||
<button className="absolute left-10 text-[42px] text-white/42" onClick={() => setHorizontalAngle((value) => Math.max(-180, value - 10))} type="button">‹</button>
|
||
<button className="absolute right-10 text-[42px] text-white/42" onClick={() => setHorizontalAngle((value) => Math.min(180, value + 10))} type="button">›</button>
|
||
<div className="relative h-[124px] w-[124px] overflow-hidden rounded-[10px] border border-white/50 bg-white shadow-[0_14px_34px_rgba(0,0,0,0.32)]">
|
||
<img alt="多角度参考图" className="h-full w-full object-contain" draggable={false} onDragStart={preventNativeImageDrag} src={sourceUrl} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-w-0 flex-1 pt-4">
|
||
<AngleSlider label="水平环绕" max={180} min={-180} onChange={setHorizontalAngle} suffix="°" value={horizontalAngle} />
|
||
<AngleSlider label="垂直俯仰" max={45} min={-45} onChange={setVerticalAngle} suffix="°" value={verticalAngle} />
|
||
<AngleSlider label="景别缩放" max={100} min={0} onChange={setZoom} value={zoom} valueLabel={zoom < 35 ? "远景" : zoom > 70 ? "近景" : "中景"} />
|
||
<div className="mt-10 flex items-center gap-8">
|
||
<span className="w-[120px] text-[26px] text-white/42">提示词</span>
|
||
<button
|
||
aria-pressed={promptEnabled}
|
||
className={cn(
|
||
"relative h-[30px] w-[54px] cursor-pointer rounded-full bg-white/14 transition",
|
||
promptEnabled && "bg-[#5f78ff]"
|
||
)}
|
||
onClick={() => setPromptEnabled((value) => !value)}
|
||
type="button"
|
||
>
|
||
<span
|
||
className="absolute top-1 h-[22px] w-[22px] rounded-full bg-white shadow-[0_2px_8px_rgba(0,0,0,0.22)] transition-all"
|
||
style={{ left: promptEnabled ? 28 : 4 }}
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
className="absolute bottom-8 left-14 flex h-12 cursor-pointer items-center gap-3 rounded-[10px] px-2 text-[24px] text-white/38 transition hover:bg-white/8 hover:text-white/70"
|
||
onClick={reset}
|
||
type="button"
|
||
>
|
||
↺
|
||
<span>重置参数</span>
|
||
</button>
|
||
<div className="absolute bottom-8 right-8 flex items-center gap-5">
|
||
<span className="flex items-center gap-2 text-[22px] text-white/56">
|
||
<span className="text-white/44">⚡</span>
|
||
1
|
||
</span>
|
||
<button
|
||
className="flex h-[64px] w-[64px] cursor-pointer items-center justify-center rounded-[14px] bg-white text-[42px] leading-none text-black transition hover:bg-white/90"
|
||
onClick={() => onApply({ horizontalAngle, promptEnabled, verticalAngle, zoom })}
|
||
type="button"
|
||
>
|
||
↑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AngleSlider({
|
||
label,
|
||
max,
|
||
min,
|
||
onChange,
|
||
suffix = "",
|
||
value,
|
||
valueLabel,
|
||
}: {
|
||
label: string;
|
||
max: number;
|
||
min: number;
|
||
onChange: (value: number) => void;
|
||
suffix?: string;
|
||
value: number;
|
||
valueLabel?: string;
|
||
}) {
|
||
return (
|
||
<div className="mb-10 grid grid-cols-[128px_1fr_86px] items-center gap-6">
|
||
<span className="text-[26px] text-white/42">{label}</span>
|
||
<input
|
||
className="h-[4px] w-full accent-white"
|
||
max={max}
|
||
min={min}
|
||
onChange={(event) => onChange(Number(event.target.value))}
|
||
type="range"
|
||
value={value}
|
||
/>
|
||
<span className="text-right text-[26px] text-white/82">{valueLabel || `${Math.round(value)}${suffix}`}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ImageToolbarActionGlyph({ action }: { action: Extract<ImageToolbarAction, "annotate" | "rotate" | "download" | "preview"> }) {
|
||
if (action === "annotate") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px]" fill="none" viewBox="0 0 26 26">
|
||
<path d="M5.2 19.2c4.8-1.4 4.1-6.6 8.1-8.8l4.5-2.5 2.2 3.8-4.5 2.6c-3.8 2.1-4.6 5.8-9.6 7" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
|
||
<path d="m16.8 8.6 2.3 4" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
if (action === "rotate") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px]" fill="none" viewBox="0 0 26 26">
|
||
<path d="M13 5v16" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
|
||
<path d="M6.4 11.2c1.2-2.1 3.7-3.4 6.6-3.4 4.2 0 7.6 2.2 7.6 5s-3.4 5-7.6 5c-2.9 0-5.4-1.1-6.6-3.1" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
if (action === "download") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px]" fill="none" viewBox="0 0 26 26">
|
||
<path d="M13 4.5v11" stroke="currentColor" strokeLinecap="round" strokeWidth="2.1" />
|
||
<path d="m8.8 11.5 4.2 4.2 4.2-4.2" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.1" />
|
||
<path d="M6.5 20.5h13" stroke="currentColor" strokeLinecap="round" strokeWidth="2.1" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px]" fill="none" viewBox="0 0 26 26">
|
||
<path d="M9.4 5.6H5.8v3.6M16.6 5.6h3.6v3.6M9.4 20.4H5.8v-3.6M16.6 20.4h3.6v-3.6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.1" />
|
||
<path d="M8 8 5.8 5.8M18 8l2.2-2.2M8 18l-2.2 2.2M18 18l2.2 2.2" stroke="currentColor" strokeLinecap="round" strokeWidth="2.1" />
|
||
</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({
|
||
anchor,
|
||
items,
|
||
}: {
|
||
anchor?: PresetControlAnchor;
|
||
items: Array<[string, string]>;
|
||
}) {
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute flex h-[58px] 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]"
|
||
style={
|
||
anchor
|
||
? {
|
||
left: anchor.centerX,
|
||
top: anchor.topY - (24 * anchor.zoom + 10),
|
||
transform: `translateX(-50%) translateY(-100%) scale(${OFFICIAL_IMAGE_TOOLBAR_SCALE})`,
|
||
transformOrigin: "bottom center",
|
||
}
|
||
: { left: "50%", top: 94, transform: "translateX(-50%)" }
|
||
}
|
||
>
|
||
{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-[720px]">
|
||
<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="h-[158px]">
|
||
<OfficialAudioCard mediaUrl={AUDIO_SOURCE_URL} selected={false} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PresetComposerPanel({
|
||
anchor,
|
||
credit,
|
||
onSubmit,
|
||
prompt,
|
||
submitMessage,
|
||
thumbnailUrl,
|
||
title,
|
||
}: {
|
||
anchor?: PresetControlAnchor;
|
||
credit: string;
|
||
onSubmit?: (prompt: string) => void;
|
||
prompt: string;
|
||
submitMessage: string;
|
||
thumbnailUrl?: string;
|
||
title: string;
|
||
}) {
|
||
const [draftPrompt, setDraftPrompt] = useState(prompt);
|
||
|
||
useEffect(() => {
|
||
setDraftPrompt(prompt);
|
||
}, [prompt]);
|
||
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute overflow-visible"
|
||
style={
|
||
anchor
|
||
? {
|
||
height: PRESET_COMPOSER_BASE_HEIGHT * OFFICIAL_COMPOSER_SCALE,
|
||
left: anchor.centerX,
|
||
top: anchor.bottomY + 10,
|
||
transform: "translateX(-50%)",
|
||
width: PROMPT_COMPOSER_BASE_WIDTH * OFFICIAL_COMPOSER_SCALE,
|
||
}
|
||
: {
|
||
bottom: 64,
|
||
height: PRESET_COMPOSER_BASE_HEIGHT * OFFICIAL_COMPOSER_SCALE,
|
||
left: "50%",
|
||
transform: "translateX(-50%)",
|
||
width: PROMPT_COMPOSER_BASE_WIDTH * OFFICIAL_COMPOSER_SCALE,
|
||
}
|
||
}
|
||
>
|
||
<div
|
||
className="relative h-[238px] w-[895px] rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]"
|
||
style={{ transform: `scale(${OFFICIAL_COMPOSER_SCALE})`, transformOrigin: "top left" }}
|
||
>
|
||
<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>
|
||
<textarea
|
||
className="h-[78px] w-full resize-none bg-transparent text-[20px] leading-8 text-white/64 outline-none placeholder:text-white/38"
|
||
onChange={(event) => setDraftPrompt(event.target.value)}
|
||
placeholder="描述你想要生成的画面内容,按 / 呼出指令,@引用素材"
|
||
value={draftPrompt}
|
||
/>
|
||
<div className="absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden 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("积分估算接口待接入。")} />
|
||
</div>
|
||
<button
|
||
className="absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white"
|
||
onClick={() => {
|
||
if (onSubmit) {
|
||
onSubmit(draftPrompt);
|
||
return;
|
||
}
|
||
toast.info(submitMessage);
|
||
}}
|
||
type="button"
|
||
>
|
||
↑
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CharacterThreeViewScene() {
|
||
const [toolbarMenu, setToolbarMenu] = useState<SceneToolbarMenu>(null);
|
||
const sceneActions: Array<{
|
||
action: Extract<ImageToolbarAction, "annotate" | "rotate" | "download" | "preview">;
|
||
message: string;
|
||
}> = [
|
||
{ action: "annotate", message: "绘制编辑待接入。" },
|
||
{ action: "rotate", message: "对比预览待接入。" },
|
||
{ action: "download", message: "下载素材待接入。" },
|
||
{ action: "preview", message: "全屏预览待接入。" },
|
||
];
|
||
|
||
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" />
|
||
{sceneActions.map((item) => (
|
||
<button
|
||
className="flex h-10 w-10 items-center justify-center rounded-[8px] text-white/84 hover:bg-white/8"
|
||
key={item.action}
|
||
onClick={() => toast.info(item.message)}
|
||
type="button"
|
||
>
|
||
<ImageToolbarActionGlyph action={item.action} />
|
||
</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 whitespace-nowrap 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 className="whitespace-nowrap">{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({
|
||
anchor,
|
||
menu,
|
||
onClose,
|
||
onSelect,
|
||
}: {
|
||
anchor?: PresetControlAnchor;
|
||
menu: Exclude<SceneToolbarMenu, null>;
|
||
onClose: () => void;
|
||
onSelect?: (option?: string) => void;
|
||
}) {
|
||
const [lightingPrompt, setLightingPrompt] = useState("");
|
||
const [customGridOpen, setCustomGridOpen] = useState(false);
|
||
const popoverOffset: Record<Exclude<SceneToolbarMenu, null>, number> = {
|
||
crop: 170,
|
||
grid: -38,
|
||
hd: 70,
|
||
lighting: -145,
|
||
orbit: -245,
|
||
panorama: -360,
|
||
};
|
||
const officialPopoverStyle = {
|
||
left: anchor ? anchor.centerX + popoverOffset[menu] : `calc(50% + ${popoverOffset[menu]}px)`,
|
||
top: anchor ? anchor.topY - (24 * anchor.zoom + 10) + 8 : 168,
|
||
};
|
||
const popoverSurfaceStyle = {
|
||
background: "rgba(31, 31, 31, 0.92)",
|
||
border: "0.5px solid #363636",
|
||
borderRadius: 12,
|
||
boxShadow: "0 4px 10px rgba(0, 0, 0, 0.2), 0 2px 4px rgba(0, 0, 0, 0.12)",
|
||
WebkitBackdropFilter: "blur(16px)",
|
||
backdropFilter: "blur(16px)",
|
||
};
|
||
const quickOptions: Record<"orbit" | "panorama", string[]> = {
|
||
orbit: ["正侧背三视角", "八方向参考", "环绕镜头"],
|
||
panorama: ["全景", "半身", "近景"],
|
||
};
|
||
const nineGridOptions = [
|
||
"多机位九宫格",
|
||
"剧情推演四宫格",
|
||
"25宫格连贯分镜",
|
||
"电影级光影校正",
|
||
"角色三视图生成",
|
||
"画面推演 - 3秒后",
|
||
"画面推演 - 5秒前",
|
||
];
|
||
const hdToolOptions: Array<{ key: string; label: string; active?: boolean }> = [
|
||
{ key: "upscale", label: "高清", active: true },
|
||
{ key: "outpaint", label: "扩图" },
|
||
{ key: "repaint", label: "重绘" },
|
||
{ key: "erase", label: "擦除" },
|
||
{ key: "removeBg", label: "抠图" },
|
||
{ key: "crop", label: "裁剪" },
|
||
];
|
||
const cropOptions = [
|
||
{ type: 4, label: "4宫格 (2×2)" },
|
||
{ type: 9, label: "9宫格 (3×3)" },
|
||
{ type: 16, label: "16宫格 (4×4)" },
|
||
{ type: 25, label: "25宫格 (5×5)" },
|
||
];
|
||
|
||
if (menu === "lighting") {
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute z-40 w-[420px] -translate-x-1/2 p-4 text-white"
|
||
style={{ ...officialPopoverStyle, ...popoverSurfaceStyle }}
|
||
>
|
||
<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"
|
||
onChange={(event) => setLightingPrompt(event.target.value)}
|
||
placeholder="简单描述你想实现的打光效果,或者情绪风格"
|
||
value={lightingPrompt}
|
||
/>
|
||
<button
|
||
className="mt-3 h-10 w-full rounded-[10px] bg-white font-semibold text-black hover:bg-white/90"
|
||
onClick={() => onSelect?.(lightingPrompt.trim() || "柔和自然光")}
|
||
type="button"
|
||
>
|
||
应用打光
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (menu === "grid") {
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute z-40 flex w-[360px] -translate-x-1/2 flex-col p-3 font-sans"
|
||
role="menu"
|
||
style={{ ...officialPopoverStyle, ...popoverSurfaceStyle }}
|
||
>
|
||
{nineGridOptions.map((option) => (
|
||
<button
|
||
className="flex h-[70px] w-full cursor-pointer items-center gap-7 rounded-[10px] px-5 text-left transition-colors duration-200 hover:bg-white/8 active:bg-white/12"
|
||
key={option}
|
||
onClick={() => onSelect?.(option)}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
role="menuitem"
|
||
type="button"
|
||
>
|
||
<NineGridPresetGlyph />
|
||
<span className="min-w-0 flex-1 truncate text-[25px] font-normal leading-snug text-white/86">
|
||
{option}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (menu === "hd") {
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute z-40 flex w-[240px] -translate-x-1/2 flex-col gap-1.5 p-2 font-sans"
|
||
role="menu"
|
||
style={{ ...officialPopoverStyle, ...popoverSurfaceStyle }}
|
||
>
|
||
{hdToolOptions.map((option) => (
|
||
<button
|
||
className={cn(
|
||
"flex h-[56px] w-full shrink-0 items-center gap-5 rounded-[10px] px-4 text-left text-[25px] font-normal leading-snug text-white/86 transition-colors",
|
||
option.active ? "bg-white/16" : "hover:bg-white/8 active:bg-white/12"
|
||
)}
|
||
key={option.key}
|
||
onClick={() => onSelect?.(option.label)}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
role="menuitem"
|
||
type="button"
|
||
>
|
||
<ImageToolMenuGlyph tool={option.key} />
|
||
<span className="min-w-0 flex-1">{option.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (menu === "crop") {
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute z-40 flex w-[300px] -translate-x-1/2 flex-col p-3 font-sans"
|
||
role="menu"
|
||
style={{ ...officialPopoverStyle, ...popoverSurfaceStyle }}
|
||
>
|
||
{cropOptions.map((option) => (
|
||
<button
|
||
className="flex h-[70px] w-full cursor-pointer items-center rounded-[10px] px-5 text-left transition-colors duration-200 hover:bg-white/8 active:bg-white/12"
|
||
key={option.type}
|
||
onClick={() => onSelect?.(option.label)}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
role="menuitem"
|
||
type="button"
|
||
>
|
||
<span className="min-w-0 truncate text-[25px] font-normal leading-snug text-white/86">
|
||
{option.label}
|
||
</span>
|
||
</button>
|
||
))}
|
||
<div className="mx-4 my-2 h-px bg-white/12" />
|
||
<div onMouseEnter={() => setCustomGridOpen(true)} onMouseLeave={() => setCustomGridOpen(false)}>
|
||
<button
|
||
className={cn(
|
||
"flex h-[70px] w-full cursor-pointer items-center justify-between rounded-[10px] px-5 text-left transition-colors duration-200 hover:bg-white/8 active:bg-white/12",
|
||
customGridOpen && "bg-white/8"
|
||
)}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
role="menuitem"
|
||
type="button"
|
||
>
|
||
<span className="min-w-0 truncate text-[25px] font-normal leading-snug text-white/86">自定义</span>
|
||
<ChevronDown className="h-5 w-5 shrink-0 -rotate-90 text-white/60" />
|
||
</button>
|
||
{customGridOpen ? (
|
||
<div
|
||
className="absolute left-full top-0 ml-1.5 p-1.5"
|
||
onMouseEnter={() => setCustomGridOpen(true)}
|
||
onMouseLeave={() => setCustomGridOpen(false)}
|
||
style={popoverSurfaceStyle}
|
||
>
|
||
<CustomGridPicker onSelect={(rows, columns) => onSelect?.(`${rows} x ${columns}`)} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute z-40 w-[180px] -translate-x-1/2 p-1.5 text-white"
|
||
style={{ ...officialPopoverStyle, ...popoverSurfaceStyle }}
|
||
>
|
||
{quickOptions[menu].map((option) => (
|
||
<button
|
||
className="flex h-9 w-full items-center justify-between rounded-[8px] px-3 text-left text-[13px] text-white/76 hover:bg-white/8 hover:text-white"
|
||
key={option}
|
||
onClick={() => onSelect?.(option)}
|
||
type="button"
|
||
>
|
||
{option}
|
||
<span className="text-white/28">›</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NineGridPresetGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-8 w-8 shrink-0 text-white/46" fill="none" viewBox="0 0 20 20">
|
||
<path d="M3 7.2 10 3.4l7 3.8-7 3.8-7-3.8Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.5" />
|
||
<path d="m3 10.2 7 3.8 7-3.8M3 13.2l7 3.8 7-3.8" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function ImageToolMenuGlyph({ tool }: { tool: string }) {
|
||
if (tool === "upscale") {
|
||
return (
|
||
<span className="flex h-[24px] min-w-[36px] items-center justify-center rounded-[4px] border border-white/34 text-[14px] font-bold leading-none text-white/46">
|
||
HD
|
||
</span>
|
||
);
|
||
}
|
||
|
||
if (tool === "outpaint") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px] shrink-0 text-white/46" fill="none" viewBox="0 0 14 14">
|
||
<path d="M1.8 5V1.8H5M9 1.8h3.2V5M12.2 9v3.2H9M5 12.2H1.8V9" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.4" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
if (tool === "repaint") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px] shrink-0 text-white/46" fill="none" viewBox="0 0 14 14">
|
||
<path d="M3 10.8 10.8 3l1.2 1.2-7.8 7.8H3v-1.2Z" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.3" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
if (tool === "erase") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px] shrink-0 text-white/46" fill="none" viewBox="0 0 14 14">
|
||
<path d="m2.5 8.5 5.8-5.8 3 3-5.8 5.8H3.2L2.5 8.5Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.3" />
|
||
<path d="M6.2 11.5h5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.3" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
if (tool === "removeBg") {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px] shrink-0 text-white/46" fill="none" viewBox="0 0 14 14">
|
||
<path d="M2 2h10v10H2V2Z" stroke="currentColor" strokeWidth="1.2" />
|
||
<path d="M2 12 12 2M2 8l6-6M6 12l6-6" stroke="currentColor" strokeWidth="1.1" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<svg aria-hidden="true" className="h-[26px] w-[26px] shrink-0 text-white/46" fill="none" viewBox="0 0 14 14">
|
||
<path d="M4 1.8v8.2h8.2M1.8 4h8.2v8.2" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.3" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function CustomGridPicker({ onSelect }: { onSelect: (rows: number, columns: number) => void }) {
|
||
const [hovered, setHovered] = useState({ columns: 0, rows: 0 });
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2.5 p-2">
|
||
<div className="flex items-center justify-between px-0.5">
|
||
<span className="text-[13px] text-white/52">自定义宫格</span>
|
||
<span className="text-[13px] font-medium text-white/86">
|
||
{hovered.rows > 0 && hovered.columns > 0 ? `${hovered.columns} x ${hovered.rows}` : ""}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-5 gap-1" onMouseLeave={() => setHovered({ columns: 0, rows: 0 })}>
|
||
{Array.from({ length: 25 }).map((_, index) => {
|
||
const rows = Math.floor(index / 5) + 1;
|
||
const columns = (index % 5) + 1;
|
||
const active = rows <= hovered.rows && columns <= hovered.columns;
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"h-8 w-8 rounded border transition-colors duration-75",
|
||
active ? "border-blue-400/60 bg-blue-500/40" : "border-white/12 bg-white/8 hover:bg-white/12"
|
||
)}
|
||
key={index}
|
||
onClick={() => onSelect(rows, columns)}
|
||
onMouseEnter={() => setHovered({ columns, rows })}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
type="button"
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</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({
|
||
anchor,
|
||
nodes = [],
|
||
onSubmit,
|
||
referenceThumbnailUrl = CHARACTER_IMAGE_URL,
|
||
}: {
|
||
anchor?: PresetControlAnchor;
|
||
nodes?: CloneNode[];
|
||
onSubmit?: (payload: ComposerSubmitPayload) => void;
|
||
referenceThumbnailUrl?: string;
|
||
}) {
|
||
const [menu, setMenu] = useState<ComposerMenu>(null);
|
||
const [prompt, setPrompt] = useState("");
|
||
const [references, setReferences] = useState<string[]>([]);
|
||
const [slashScene, setSlashScene] = useState<string | null>(null);
|
||
const [settings, setSettings] = useState<ComposerSettings>({
|
||
camera: "摄像机",
|
||
count: "1张",
|
||
model: "Lib Nano Pro",
|
||
panorama: "全景",
|
||
ratio: "16:9",
|
||
resolution: "2K",
|
||
});
|
||
const referenceNodes = useMemo(
|
||
() => nodes.filter((node) => node.kind !== "script" && node.inputType !== "audio"),
|
||
[nodes]
|
||
);
|
||
|
||
const insertReference = useCallback((node: CloneNode) => {
|
||
const label = node.title || "节点";
|
||
setPrompt((current) => `${current.replace(/@$/, "").trimEnd()} @${label} `);
|
||
setReferences((current) => (current.includes(node.id) ? current : [...current, node.id]));
|
||
setMenu(null);
|
||
}, []);
|
||
|
||
const insertSlashScene = useCallback((scene: string) => {
|
||
setSlashScene(scene);
|
||
setPrompt((current) => `${current.replace(/\/$/, "").trimEnd()} /${scene} `);
|
||
setMenu(null);
|
||
}, []);
|
||
|
||
const applyComposerOption = useCallback((activeMenu: Exclude<ComposerMenu, null>, option: string) => {
|
||
if (activeMenu === "model" || activeMenu === "ratio" || activeMenu === "resolution" || activeMenu === "camera" || activeMenu === "panorama" || activeMenu === "count") {
|
||
setSettings((current) => ({ ...current, [activeMenu]: option }));
|
||
} else if (activeMenu === "style" || activeMenu === "mark" || activeMenu === "focus" || activeMenu === "translate" || activeMenu === "settings") {
|
||
setPrompt((current) => `${current.trimEnd()} ${option}`.trimStart());
|
||
}
|
||
setMenu(null);
|
||
toast.info(`${option} 已应用,生成接口待接入。`);
|
||
}, []);
|
||
|
||
const onPromptChange = useCallback((event: ReactChangeEvent<HTMLTextAreaElement>) => {
|
||
const value = event.target.value;
|
||
setPrompt(value);
|
||
const beforeCursor = value.slice(0, event.target.selectionStart ?? value.length);
|
||
if (beforeCursor.endsWith("@")) {
|
||
setMenu("reference");
|
||
return;
|
||
}
|
||
if (beforeCursor.endsWith("/")) {
|
||
setMenu("slash");
|
||
return;
|
||
}
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
className="pointer-events-auto absolute overflow-visible"
|
||
style={
|
||
anchor
|
||
? {
|
||
height: PROMPT_COMPOSER_BASE_HEIGHT * OFFICIAL_COMPOSER_SCALE,
|
||
left: anchor.centerX,
|
||
top: anchor.bottomY + 10,
|
||
transform: "translateX(-50%)",
|
||
width: PROMPT_COMPOSER_BASE_WIDTH * OFFICIAL_COMPOSER_SCALE,
|
||
}
|
||
: {
|
||
bottom: 64,
|
||
height: PROMPT_COMPOSER_BASE_HEIGHT * OFFICIAL_COMPOSER_SCALE,
|
||
left: "50%",
|
||
transform: "translateX(-50%)",
|
||
width: PROMPT_COMPOSER_BASE_WIDTH * OFFICIAL_COMPOSER_SCALE,
|
||
}
|
||
}
|
||
>
|
||
<div
|
||
className="relative h-[286px] w-[895px] rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]"
|
||
style={{ transform: `scale(${OFFICIAL_COMPOSER_SCALE})`, transformOrigin: "top left" }}
|
||
>
|
||
<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={referenceThumbnailUrl} />
|
||
<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>
|
||
{references.length > 0 || slashScene ? (
|
||
<div className="mt-3 flex max-w-[780px] flex-wrap gap-2">
|
||
{slashScene ? (
|
||
<button
|
||
className="rounded-[8px] border border-[#42eaff]/24 bg-[#0e6371]/40 px-3 py-1 text-[13px] text-[#72f1ff]"
|
||
onClick={() => setMenu("slash")}
|
||
type="button"
|
||
>
|
||
/ {slashScene}
|
||
</button>
|
||
) : null}
|
||
{references.map((nodeId) => {
|
||
const node = nodes.find((item) => item.id === nodeId);
|
||
if (!node) return null;
|
||
const previewUrl = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
|
||
return (
|
||
<button
|
||
className="flex h-7 items-center gap-2 rounded-[8px] border border-white/10 bg-white/8 px-2 text-[12px] text-white/76"
|
||
key={nodeId}
|
||
onClick={() => setMenu("reference")}
|
||
type="button"
|
||
>
|
||
{previewUrl ? <img alt="" className="h-5 w-5 rounded-[5px] object-cover" src={previewUrl} /> : <ImageIcon className="h-4 w-4" />}
|
||
@{node.title}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
{menu === "reference" ? (
|
||
<ComposerReferencePopover nodes={referenceNodes} onSelect={insertReference} />
|
||
) : menu === "slash" ? (
|
||
<ComposerSlashPopover onSelect={insertSlashScene} />
|
||
) : menu === "focus" ? (
|
||
<ComposerFocusPopover onApply={(value) => applyComposerOption("focus", value)} />
|
||
) : menu ? (
|
||
<ComposerPopover menu={menu} onSelect={(option) => applyComposerOption(menu, option)} />
|
||
) : null}
|
||
|
||
<textarea
|
||
className="mt-5 h-[86px] w-full resize-none bg-transparent text-[20px] leading-8 text-white/64 outline-none placeholder:text-white/42"
|
||
onChange={onPromptChange}
|
||
onFocus={() => {
|
||
if (prompt.endsWith("@")) setMenu("reference");
|
||
if (prompt.endsWith("/")) setMenu("slash");
|
||
}}
|
||
placeholder="描述你想要生成的画面内容,按 / 呼出指令,@引用素材"
|
||
value={prompt}
|
||
/>
|
||
|
||
<div className="absolute bottom-5 left-5 right-[84px] flex items-center gap-6 overflow-hidden text-[18px] text-white/78">
|
||
<ComposerControl active={menu === "model"} label={settings.model} icon="◉" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
|
||
<ComposerControl active={menu === "ratio"} label={settings.ratio} icon="▭" dropdown onClick={() => setMenu((current) => current === "ratio" ? null : "ratio")} />
|
||
<ComposerControl active={menu === "resolution"} label={settings.resolution} dropdown onClick={() => setMenu((current) => current === "resolution" ? null : "resolution")} />
|
||
<ComposerControl active={menu === "camera"} label={settings.camera} icon="▣" onClick={() => setMenu((current) => current === "camera" ? null : "camera")} />
|
||
<ComposerControl active={menu === "panorama"} label={settings.panorama} 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={settings.count} dropdown onClick={() => setMenu((current) => current === "count" ? null : "count")} />
|
||
<ComposerControl label="14" icon="⚡" muted onClick={() => toast.info("积分估算接口待接入。")} />
|
||
</div>
|
||
<button
|
||
className="absolute bottom-5 right-5 flex h-11 w-11 items-center justify-center rounded-[12px] bg-white/76 text-[24px] text-black hover:bg-white"
|
||
onClick={() =>
|
||
onSubmit
|
||
? onSubmit({
|
||
prompt,
|
||
references,
|
||
settings,
|
||
slashScene,
|
||
})
|
||
: 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 ComposerFocusPopover({ onApply }: { onApply: (value: string) => void }) {
|
||
return (
|
||
<div className="absolute left-[178px] top-[124px] 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={() => onApply("聚焦区域")}
|
||
type="button"
|
||
>
|
||
进入聚焦编辑
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComposerReferencePopover({
|
||
nodes,
|
||
onSelect,
|
||
}: {
|
||
nodes: CloneNode[];
|
||
onSelect: (node: CloneNode) => void;
|
||
}) {
|
||
return (
|
||
<div className="absolute left-5 top-[124px] z-50 w-[320px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
|
||
<div className="px-3 pb-2 pt-1 text-[12px] text-white/42">节点</div>
|
||
{nodes.length === 0 ? (
|
||
<div className="rounded-[10px] border border-white/8 bg-white/5 px-3 py-4 text-[13px] text-white/42">暂无可引用节点</div>
|
||
) : (
|
||
nodes.map((node) => {
|
||
const previewUrl = getDraftString(node, "mediaUrl") || getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
|
||
const Icon = node.inputType === "video" ? Video : node.kind === "text" ? FileText : ImageIcon;
|
||
return (
|
||
<button
|
||
className="flex h-11 w-full items-center gap-3 rounded-[9px] px-2 text-left text-[13px] text-white/76 hover:bg-white/8 hover:text-white"
|
||
key={node.id}
|
||
onClick={() => onSelect(node)}
|
||
type="button"
|
||
>
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-[7px] bg-white/10 text-white/60">
|
||
{previewUrl ? <img alt="" className="h-full w-full object-cover" src={previewUrl} /> : <Icon className="h-4 w-4" />}
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate">{node.title}</span>
|
||
<span className="block truncate text-[11px] text-white/36">{node.subtitle || node.inputType || node.kind}</span>
|
||
</span>
|
||
<span className="text-white/28">↵</span>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComposerSlashPopover({ onSelect }: { onSelect: (scene: string) => void }) {
|
||
return (
|
||
<div className="absolute left-5 top-[124px] z-50 w-[320px] rounded-[14px] border border-white/10 bg-[#1f1f1f] p-2 shadow-[0_24px_80px_rgba(0,0,0,0.5)]">
|
||
<div className="px-3 pb-2 pt-1 text-[12px] text-white/42">指令</div>
|
||
{SLASH_IMAGE_ITEMS.map((item) => (
|
||
<button
|
||
className="flex h-10 w-full items-center justify-between rounded-[9px] px-3 text-left text-[13px] text-white/76 hover:bg-white/8 hover:text-white"
|
||
key={item}
|
||
onClick={() => onSelect(item)}
|
||
type="button"
|
||
>
|
||
<span>{item}</span>
|
||
<span className="rounded-[6px] bg-white/8 px-2 py-0.5 text-[11px] text-white/38">/</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComposerPopover({
|
||
menu,
|
||
onSelect,
|
||
}: {
|
||
menu: Exclude<ComposerMenu, "focus" | "reference" | "slash" | null>;
|
||
onSelect: (option: string) => void;
|
||
}) {
|
||
const options: Record<Exclude<ComposerMenu, "focus" | "reference" | "slash" | 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", "自动翻译"],
|
||
};
|
||
|
||
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={() => onSelect(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 CanvasCommandPalette({
|
||
onClose,
|
||
onCreate,
|
||
onGallerySelect,
|
||
onUploadFiles,
|
||
state,
|
||
}: {
|
||
onClose: () => void;
|
||
onCreate: (kind: CanvasCreateKind) => void;
|
||
onGallerySelect: () => void;
|
||
onUploadFiles: (files: File[]) => void;
|
||
state: NonNullable<CanvasCommandState>;
|
||
}) {
|
||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
return (
|
||
<div
|
||
className="fixed z-[90] flex w-[240px] flex-col gap-2 rounded-[16px] border border-white/10 bg-[#202020]/96 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[32px]"
|
||
data-canvas-overlay="true"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
style={{
|
||
left: `min(${state.screenX}px, calc(100vw - 256px))`,
|
||
top: `min(${state.screenY}px, calc(100vh - 540px))`,
|
||
}}
|
||
>
|
||
<input
|
||
accept="image/*,video/*,audio/*"
|
||
className="hidden"
|
||
multiple
|
||
onChange={(event) => {
|
||
const files = Array.from(event.target.files || []);
|
||
if (files.length > 0) onUploadFiles(files);
|
||
event.currentTarget.value = "";
|
||
}}
|
||
ref={uploadInputRef}
|
||
type="file"
|
||
/>
|
||
<div className="mb-2 flex items-center justify-between px-2 py-1">
|
||
<h4 className="text-[14px] font-medium leading-5 text-white/60">添加节点</h4>
|
||
<button className="flex h-7 w-7 items-center justify-center rounded-[8px] text-white/48 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{ADD_NODE_MODULES.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<button
|
||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||
key={item.kind}
|
||
onClick={() => onCreate(item.kind)}
|
||
type="button"
|
||
>
|
||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||
<Icon className="h-5 w-5" />
|
||
</span>
|
||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||
<span className="flex items-center gap-1.5 text-[14px] font-medium leading-5">
|
||
{item.label}
|
||
{item.badge ? <span className="rounded-[4px] bg-white/10 px-1.5 py-0.5 text-[10px] leading-3 text-white/50">{item.badge}</span> : null}
|
||
</span>
|
||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||
{item.description}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
<h4 className="px-2 py-1 text-[14px] font-medium leading-5 text-white/60">添加资源</h4>
|
||
<button
|
||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||
onClick={() => uploadInputRef.current?.click()}
|
||
type="button"
|
||
>
|
||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||
<Upload className="h-5 w-5" />
|
||
</span>
|
||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||
<span className="text-[14px] font-medium leading-5">上传</span>
|
||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||
可上传图片、视频、音频文件
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
<button
|
||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||
onClick={onGallerySelect}
|
||
type="button"
|
||
>
|
||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||
<Layers className="h-5 w-5" />
|
||
</span>
|
||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||
<span className="text-[14px] font-medium leading-5">从图库选择</span>
|
||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||
从历史生成中选择素材
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ConnectionAddMenu({
|
||
node,
|
||
onClose,
|
||
onCreate,
|
||
state,
|
||
}: {
|
||
node: CloneNode;
|
||
onClose: () => void;
|
||
onCreate: (kind: Exclude<AddModuleKind, "audio">) => void;
|
||
state: NonNullable<ConnectionMenuState>;
|
||
}) {
|
||
const title = state.side === "target" ? "添加上下文" : "引用该节点生成";
|
||
|
||
return (
|
||
<div
|
||
className="fixed z-[91] flex w-[240px] flex-col gap-2 rounded-[16px] border border-white/10 bg-[#202020]/96 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[32px]"
|
||
data-canvas-overlay="true"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
style={{
|
||
left:
|
||
state.side === "target"
|
||
? `max(12px, min(${state.screenX - 252}px, calc(100vw - 252px)))`
|
||
: `min(${state.screenX + 12}px, calc(100vw - 252px))`,
|
||
top: `min(${state.screenY - 120}px, calc(100vh - 330px))`,
|
||
}}
|
||
>
|
||
<div className="flex items-start justify-between gap-2 px-2 py-1">
|
||
<div className="min-w-0">
|
||
<h4 className="text-[14px] font-medium leading-5 text-white/60">{title}</h4>
|
||
<div className="mt-1 truncate text-[11px] text-white/34">{node.title}</div>
|
||
</div>
|
||
<button className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-white/42 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
{CONNECTION_ADD_MODULES.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<button
|
||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||
key={item.kind}
|
||
onClick={() => onCreate(item.kind)}
|
||
type="button"
|
||
>
|
||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||
<Icon className="h-5 w-5" />
|
||
</span>
|
||
<span className="text-[14px] font-medium leading-5">{item.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NodeContextMenu({
|
||
node,
|
||
onClose,
|
||
onConfigure,
|
||
onCopy,
|
||
onDelete,
|
||
onDuplicate,
|
||
onRun,
|
||
state,
|
||
}: {
|
||
node: CloneNode;
|
||
onClose: () => void;
|
||
onConfigure: () => void;
|
||
onCopy: () => void;
|
||
onDelete: () => void;
|
||
onDuplicate: () => void;
|
||
onRun: () => void;
|
||
state: NonNullable<NodeContextState>;
|
||
}) {
|
||
const actions: Array<{ icon: LucideIcon; label: string; note: string; onClick: () => void; danger?: boolean }> = [
|
||
{ icon: Settings2, label: "配置参数", note: "打开右侧参数面板", onClick: onConfigure },
|
||
{ icon: Play, label: "立即生成", note: "执行当前节点", onClick: onRun },
|
||
{ icon: Plus, label: "复制节点", note: "在旁边创建副本", onClick: onDuplicate },
|
||
{ icon: Copy, label: "复制到剪贴板", note: "复制节点名称", onClick: onCopy },
|
||
{ icon: Trash2, label: "删除", note: "移除节点与连接", onClick: onDelete, danger: true },
|
||
];
|
||
|
||
return (
|
||
<div
|
||
className="fixed z-[92] w-[246px] rounded-[8px] border border-white/10 bg-[#202020]/98 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[10px]"
|
||
data-canvas-overlay="true"
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
style={{
|
||
left: `min(${state.screenX}px, calc(100vw - 262px))`,
|
||
top: `min(${state.screenY}px, calc(100vh - 310px))`,
|
||
}}
|
||
>
|
||
<div className="mb-2 flex items-start justify-between gap-3 px-2 py-1">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[13px] font-semibold text-white/88">{node.title}</div>
|
||
<div className="mt-1 truncate text-[11px] text-white/36">{node.subtitle || node.kind}</div>
|
||
</div>
|
||
<button className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-white/48 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="grid gap-1">
|
||
{actions.map((action) => {
|
||
const Icon = action.icon;
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"flex w-full items-center gap-3 rounded-[8px] px-2 py-2 text-left transition-colors hover:bg-white/8",
|
||
action.danger && "text-[#ff8f9c] hover:bg-[#ff6b7a]/12"
|
||
)}
|
||
key={action.label}
|
||
onClick={action.onClick}
|
||
type="button"
|
||
>
|
||
<Icon className="h-4 w-4 shrink-0" />
|
||
<span className="min-w-0">
|
||
<span className="block text-[13px] font-medium">{action.label}</span>
|
||
<span className="block truncate text-[11px] text-white/36">{action.note}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 overflow-visible" style={{ height: CANVAS_EXTENT, width: CANVAS_EXTENT }}>
|
||
{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 deltaX = endX - startX;
|
||
const curve = deltaX >= 0 ? Math.max(60, 6.25 * Math.sqrt(Math.max(deltaX, 1))) : Math.max(60, Math.min(180, Math.abs(deltaX) * 0.5));
|
||
const path = `M ${startX} ${startY} C ${startX + curve} ${startY}, ${endX - curve} ${endY}, ${endX} ${endY}`;
|
||
return (
|
||
<g key={edge.id}>
|
||
<path
|
||
d={path}
|
||
fill="none"
|
||
stroke="#86909c"
|
||
strokeLinecap="round"
|
||
strokeOpacity="0.98"
|
||
strokeWidth="2"
|
||
/>
|
||
<EdgeFlowSegments pathD={path} uniqueId={edge.id} visible />
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
type EdgePoint = { x: number; y: number };
|
||
|
||
function getPointAtSampledLength(points: EdgePoint[], totalLength: number, length: number): EdgePoint {
|
||
const safeLength = Math.max(0, Math.min(totalLength, length));
|
||
const index = (points.length - 1) * Math.max(0, Math.min(1, safeLength / totalLength));
|
||
const leftIndex = Math.floor(index);
|
||
const ratio = index - leftIndex;
|
||
if (leftIndex >= points.length - 1) return points[points.length - 1];
|
||
const left = points[leftIndex];
|
||
const right = points[leftIndex + 1];
|
||
return {
|
||
x: left.x + ratio * (right.x - left.x),
|
||
y: left.y + ratio * (right.y - left.y),
|
||
};
|
||
}
|
||
|
||
function parseSimpleEdgePath(pathD: string): { points: EdgePoint[]; totalLength: number } | null {
|
||
const line = pathD.match(/M\s*([\d.eE+-]+)\s*[, ]\s*([\d.eE+-]+)\s*L\s*([\d.eE+-]+)\s*[, ]\s*([\d.eE+-]+)/i);
|
||
const cubic = pathD.match(/M\s*([\d.eE+-]+)\s*[, ]\s*([\d.eE+-]+)\s*C\s*[\d.eE+-]+\s*[, ]\s*[\d.eE+-]+\s+[\d.eE+-]+\s*[, ]\s*[\d.eE+-]+\s+([\d.eE+-]+)\s*[, ]\s*([\d.eE+-]+)/i);
|
||
const match = line || cubic;
|
||
if (!match) return null;
|
||
const x1 = Number.parseFloat(match[1]);
|
||
const y1 = Number.parseFloat(match[2]);
|
||
const x2 = Number.parseFloat(match[3]);
|
||
const y2 = Number.parseFloat(match[4]);
|
||
const totalLength = Math.hypot(x2 - x1, y2 - y1);
|
||
if (totalLength <= 0) return null;
|
||
const points = Array.from({ length: 81 }, (_, index) => {
|
||
const ratio = index / 80;
|
||
return { x: x1 + ratio * (x2 - x1), y: y1 + ratio * (y2 - y1) };
|
||
});
|
||
return { points, totalLength };
|
||
}
|
||
|
||
function sampleEdgePath(pathD: string): { points: EdgePoint[]; totalLength: number } | null {
|
||
if (typeof document === "undefined") return null;
|
||
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||
path.setAttribute("d", pathD);
|
||
try {
|
||
const totalLength = path.getTotalLength();
|
||
if (totalLength <= 0.5) return parseSimpleEdgePath(pathD);
|
||
const points = Array.from({ length: 81 }, (_, index) => {
|
||
const point = path.getPointAtLength((index / 80) * totalLength);
|
||
return { x: point.x, y: point.y };
|
||
});
|
||
return { points, totalLength };
|
||
} catch {
|
||
return parseSimpleEdgePath(pathD);
|
||
}
|
||
}
|
||
|
||
function EdgeFlowSegments({
|
||
glowScale = 1,
|
||
pathD,
|
||
strokeWidth = 4,
|
||
uniqueId,
|
||
visible,
|
||
}: {
|
||
glowScale?: number;
|
||
pathD: string;
|
||
strokeWidth?: number;
|
||
uniqueId: string;
|
||
visible?: boolean;
|
||
}) {
|
||
const [progress, setProgress] = useState(0);
|
||
const sampledPath = useMemo(() => sampleEdgePath(pathD), [pathD]);
|
||
const safeId = useMemo(() => `libtv-edge-flow-${uniqueId.replace(/[^a-zA-Z0-9_-]/g, "-")}`, [uniqueId]);
|
||
const bounds = useMemo(() => {
|
||
const points = sampledPath?.points || [];
|
||
if (points.length === 0) return { height: 100, width: 100, x: 0, y: 0 };
|
||
const padding = 20 * glowScale;
|
||
let minX = Infinity;
|
||
let minY = Infinity;
|
||
let maxX = -Infinity;
|
||
let maxY = -Infinity;
|
||
points.forEach((point) => {
|
||
minX = Math.min(minX, point.x);
|
||
minY = Math.min(minY, point.y);
|
||
maxX = Math.max(maxX, point.x);
|
||
maxY = Math.max(maxY, point.y);
|
||
});
|
||
return {
|
||
height: maxY - minY + padding * 2,
|
||
width: maxX - minX + padding * 2,
|
||
x: minX - padding,
|
||
y: minY - padding,
|
||
};
|
||
}, [glowScale, sampledPath?.points]);
|
||
|
||
useEffect(() => {
|
||
if (!visible || !sampledPath) return;
|
||
let frame = 0;
|
||
const start = performance.now();
|
||
const tick = () => {
|
||
setProgress(((performance.now() - start) / 3000) % 1);
|
||
frame = requestAnimationFrame(tick);
|
||
};
|
||
frame = requestAnimationFrame(tick);
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [sampledPath, visible]);
|
||
|
||
if (!visible || !sampledPath) return null;
|
||
|
||
const { points, totalLength } = sampledPath;
|
||
const segmentLength = Math.max(0.12 * totalLength, 2);
|
||
const filterId = `${safeId}-filter`;
|
||
|
||
return (
|
||
<g className="edge-flow-segments" style={{ pointerEvents: "none" }}>
|
||
<defs>
|
||
<filter
|
||
colorInterpolationFilters="sRGB"
|
||
filterUnits="userSpaceOnUse"
|
||
height={bounds.height}
|
||
id={filterId}
|
||
width={bounds.width}
|
||
x={bounds.x}
|
||
y={bounds.y}
|
||
>
|
||
<feGaussianBlur in="SourceGraphic" result="blurOuter" stdDeviation={4 * glowScale} />
|
||
<feFlood floodColor="rgba(100, 180, 255, 0.45)" result="floodOuter" />
|
||
<feComposite in="blurOuter" in2="floodOuter" operator="in" result="glowOuter" />
|
||
<feGaussianBlur in="SourceGraphic" result="blurInner" stdDeviation={1.5 * glowScale} />
|
||
<feFlood floodColor="rgba(150, 210, 255, 0.7)" result="floodInner" />
|
||
<feComposite in="blurInner" in2="floodInner" operator="in" result="glowInner" />
|
||
<feMerge>
|
||
<feMergeNode in="glowOuter" />
|
||
<feMergeNode in="glowInner" />
|
||
<feMergeNode in="SourceGraphic" />
|
||
</feMerge>
|
||
</filter>
|
||
</defs>
|
||
{Array.from({ length: 3 }, (_, index) => {
|
||
const startLength = ((progress + index / 3) % 1) * totalLength;
|
||
const start = getPointAtSampledLength(points, totalLength, startLength);
|
||
const end = getPointAtSampledLength(points, totalLength, startLength + segmentLength);
|
||
const segmentPath = Array.from({ length: 13 }, (__, segmentIndex) => {
|
||
const point = getPointAtSampledLength(points, totalLength, startLength + (segmentLength * segmentIndex) / 12);
|
||
return segmentIndex === 0 ? `M ${point.x},${point.y}` : `L ${point.x},${point.y}`;
|
||
}).join(" ");
|
||
const gradientId = `${safeId}-grad-${index}`;
|
||
return (
|
||
<g key={index}>
|
||
<defs>
|
||
<linearGradient gradientUnits="userSpaceOnUse" id={gradientId} x1={start.x} x2={end.x} y1={start.y} y2={end.y}>
|
||
<stop offset="0%" stopColor="rgba(100, 180, 255, 0)" />
|
||
<stop offset="100%" stopColor="rgba(100, 180, 255, 0.9)" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path
|
||
d={segmentPath}
|
||
fill="none"
|
||
filter={`url(#${filterId})`}
|
||
stroke={`url(#${gradientId})`}
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={strokeWidth}
|
||
/>
|
||
</g>
|
||
);
|
||
})}
|
||
</g>
|
||
);
|
||
}
|
||
|
||
function CloneCanvasNode({
|
||
node,
|
||
onContextMenu,
|
||
onModuleAction,
|
||
onOpenConnectionMenu,
|
||
onPointerDown,
|
||
onTextResourceChange,
|
||
selected,
|
||
}: {
|
||
node: CloneNode;
|
||
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||
onModuleAction: (actionLabel: TargetModuleActionLabel) => void;
|
||
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
|
||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||
onTextResourceChange: (content: string) => void;
|
||
selected: boolean;
|
||
}) {
|
||
const targetModuleKind = getTargetModuleKind(node);
|
||
if (targetModuleKind) {
|
||
return (
|
||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
|
||
<TargetModuleCanvasNode kind={targetModuleKind} node={node} onAction={onModuleAction} selected={selected} />
|
||
</CanvasNodeShell>
|
||
);
|
||
}
|
||
|
||
if (node.kind === "media") {
|
||
return (
|
||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
|
||
<PresetMediaCanvasNode node={node} selected={selected} />
|
||
</CanvasNodeShell>
|
||
);
|
||
}
|
||
|
||
if (node.kind === "script") {
|
||
return (
|
||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
|
||
<PresetScriptCanvasNode node={node} onTextResourceChange={onTextResourceChange} selected={selected} />
|
||
</CanvasNodeShell>
|
||
);
|
||
}
|
||
|
||
const nodeType = getDraftString(node, "nodeType");
|
||
const Icon = nodeType === "video-clip" ? Scissors : node.inputType === "audio" ? Music2 : node.inputType === "video" ? Video : node.kind === "text" ? FileText : node.kind === "preview" ? Video : ImageIcon;
|
||
const isInput = node.kind === "input";
|
||
const isWarm = node.kind === "text" || node.kind === "audio";
|
||
const previewUrl = getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
|
||
return (
|
||
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
|
||
{isInput ? (
|
||
<div
|
||
className={cn(
|
||
"relative flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-[4px] bg-[#f8f9ff] text-center text-[#2a2d3a]",
|
||
selected && "outline outline-2 outline-[#8b96ff]"
|
||
)}
|
||
>
|
||
{previewUrl ? (
|
||
<>
|
||
<img
|
||
alt=""
|
||
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-28"
|
||
draggable={false}
|
||
onDragStart={preventNativeImageDrag}
|
||
src={previewUrl}
|
||
/>
|
||
<span className="absolute inset-0 bg-white/62" />
|
||
</>
|
||
) : null}
|
||
<span className="relative mb-3 flex h-8 w-8 items-center justify-center rounded-[8px] border border-[#dfe3ff] bg-white text-[#7a85ff]">
|
||
<Icon className="h-4 w-4" />
|
||
</span>
|
||
<div className="relative font-semibold text-[14px]">{node.title}</div>
|
||
<div className="relative mt-2 line-clamp-3 px-5 text-[11px] leading-5 text-[#6f7482]">{node.subtitle}</div>
|
||
<button className="relative mt-3 h-8 rounded-[8px] border border-[#dfe3ff] bg-white px-4 text-[11px] text-[#5161ff]" type="button">
|
||
选择文件
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<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-[0]">{node.title}</div>
|
||
</div>
|
||
<div className="flex h-[58px] w-8 items-center justify-center rounded-full border border-black/8 bg-white/62 text-center text-[13px] leading-4 text-[#272a32]">
|
||
待<br />配置
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CanvasNodeShell>
|
||
);
|
||
}
|
||
|
||
function TargetModuleCanvasNode({
|
||
kind,
|
||
node,
|
||
onAction,
|
||
selected,
|
||
}: {
|
||
kind: AddModuleKind;
|
||
node: CloneNode;
|
||
onAction: (actionLabel: TargetModuleActionLabel) => void;
|
||
selected: boolean;
|
||
}) {
|
||
const HeaderIcon = kind === "video-clip" ? Scissors : kind === "audio" ? Music2 : kind === "video" ? Video : kind === "image" ? ImageIcon : FileText;
|
||
const actions: Record<AddModuleKind, Array<{ icon: LucideIcon | "hd"; label: TargetModuleActionLabel }>> = {
|
||
audio: [{ icon: Play, label: "音频生视频" }],
|
||
image: [
|
||
{ icon: Upload, label: "图生图" },
|
||
{ icon: "hd", label: "图片高清" },
|
||
],
|
||
script: [
|
||
{ icon: FileText, label: "剧本生成分镜脚本" },
|
||
{ icon: Video, label: "视频参考生成分镜脚本" },
|
||
{ icon: UserRound, label: "角色生成分镜脚本" },
|
||
],
|
||
text: [
|
||
{ icon: FileText, label: "自己编写内容" },
|
||
{ icon: Play, label: "文生视频" },
|
||
{ icon: ImageIcon, label: "图片反推提示词" },
|
||
{ icon: Music2, label: "文字生音乐" },
|
||
],
|
||
video: [
|
||
{ icon: Layers, label: "首尾帧生成视频" },
|
||
{ icon: Sparkles, label: "首帧生成视频" },
|
||
],
|
||
"video-clip": [],
|
||
};
|
||
const actionTop =
|
||
kind === "text"
|
||
? "top-[166px]"
|
||
: kind === "image" || kind === "video"
|
||
? "top-[220px]"
|
||
: kind === "script"
|
||
? "top-[190px]"
|
||
: "top-[190px]";
|
||
|
||
return (
|
||
<div className="h-full w-full">
|
||
<div className="mb-[10px] flex h-7 items-center gap-2 text-[22px] font-semibold leading-none text-white/44">
|
||
<HeaderIcon className="h-[22px] w-[22px]" />
|
||
<span>{node.title}</span>
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"relative h-[432px] overflow-hidden rounded-[8px] border border-white/8 bg-[#252525] shadow-[0_16px_38px_rgba(0,0,0,0.24)]",
|
||
selected && "outline outline-1 outline-white/22"
|
||
)}
|
||
>
|
||
<div className={cn("absolute inset-x-0 flex justify-center text-white/18", kind === "text" || kind === "script" ? "top-[62px]" : "top-[116px]")}>
|
||
<TargetModuleHeroGraphic kind={kind} />
|
||
</div>
|
||
{kind === "video-clip" ? (
|
||
<div className="absolute inset-x-0 top-[254px] text-center text-[16px] text-white/34">
|
||
空空如也,请连接多个视频节点后操作
|
||
</div>
|
||
) : (
|
||
<div className={cn("absolute left-8", actionTop)}>
|
||
<div className="mb-5 text-[17px] text-white/42">尝试:</div>
|
||
<div className="space-y-5">
|
||
{actions[kind].map((action) => (
|
||
<button
|
||
className="flex items-center gap-3 text-[18px] leading-none text-white/82 hover:text-white"
|
||
key={action.label}
|
||
onClick={() => onAction(action.label)}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
type="button"
|
||
>
|
||
<ModuleActionIcon icon={action.icon} />
|
||
<span>{action.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TargetModuleHeroGraphic({ kind }: { kind: AddModuleKind }) {
|
||
if (kind === "text" || kind === "script") {
|
||
return (
|
||
<div className="flex w-[92px] flex-col gap-[9px]">
|
||
{[70, 70, 70, 70, 43].map((width, index) => (
|
||
<span className="h-[7px] rounded-full bg-white/16" key={`${width}-${index}`} style={{ width }} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (kind === "image") {
|
||
return <ImageIcon className="h-[92px] w-[92px]" strokeWidth={1.5} />;
|
||
}
|
||
|
||
if (kind === "audio") {
|
||
return <Music2 className="h-[86px] w-[86px]" strokeWidth={1.5} />;
|
||
}
|
||
|
||
if (kind === "video") {
|
||
return (
|
||
<span className="flex h-20 w-20 items-center justify-center rounded-[8px] bg-white/18 text-[#242424]">
|
||
<Play className="ml-1 h-11 w-11 fill-current" strokeWidth={0} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return <Scissors className="h-[92px] w-[92px]" strokeWidth={1.4} />;
|
||
}
|
||
|
||
function ModuleActionIcon({ icon }: { icon: LucideIcon | "hd" }) {
|
||
if (icon === "hd") {
|
||
return (
|
||
<span className="flex h-[18px] min-w-[24px] items-center justify-center rounded-[3px] border border-white/72 px-1 text-[10px] font-bold leading-none text-white/86">
|
||
HD
|
||
</span>
|
||
);
|
||
}
|
||
const Icon = icon;
|
||
return <Icon className="h-[18px] w-[18px] shrink-0 text-white/86" strokeWidth={2} />;
|
||
}
|
||
|
||
function normalizeGridCellVisual(value: unknown): GridCellVisual {
|
||
const record = value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||
const fit = record.fit === "contain" ? "contain" : "cover";
|
||
return {
|
||
fit,
|
||
objectPosition: typeof record.objectPosition === "string" ? record.objectPosition : "50% 50%",
|
||
scale: Number.isFinite(Number(record.scale)) ? Number(record.scale) : 1,
|
||
translateX: Number.isFinite(Number(record.translateX)) ? Number(record.translateX) : 0,
|
||
translateY: Number.isFinite(Number(record.translateY)) ? Number(record.translateY) : 0,
|
||
};
|
||
}
|
||
|
||
function normalizeGridCropTile(value: unknown) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const record = value as Record<string, unknown>;
|
||
const rows = Math.max(1, Math.round(Number(record.rows || 1)));
|
||
const columns = Math.max(1, Math.round(Number(record.columns || 1)));
|
||
const index = Math.max(0, Math.round(Number(record.index || 0)));
|
||
const rawRect = record.rect && typeof record.rect === "object" ? (record.rect as Record<string, unknown>) : null;
|
||
const fallbackRect = createEqualGridRects(rows, columns)[index] || createEqualGridRects(rows, columns)[0];
|
||
const rect: GridCropRect = rawRect
|
||
? {
|
||
height: Math.min(1, Math.max(0.02, Number(rawRect.height ?? fallbackRect.height))),
|
||
width: Math.min(1, Math.max(0.02, Number(rawRect.width ?? fallbackRect.width))),
|
||
x: Math.min(0.98, Math.max(0, Number(rawRect.x ?? fallbackRect.x))),
|
||
y: Math.min(0.98, Math.max(0, Number(rawRect.y ?? fallbackRect.y))),
|
||
}
|
||
: fallbackRect;
|
||
|
||
return {
|
||
columns,
|
||
detectedDivider: Boolean(record.detectedDivider),
|
||
index,
|
||
rect,
|
||
rows,
|
||
sourceMode: String(record.sourceMode || ""),
|
||
};
|
||
}
|
||
|
||
function GridCellImage({
|
||
alt,
|
||
src,
|
||
visual,
|
||
}: {
|
||
alt: string;
|
||
src: string;
|
||
visual: GridCellVisual;
|
||
}) {
|
||
const normalized = normalizeGridCellVisual(visual);
|
||
return (
|
||
<img
|
||
alt={alt}
|
||
className="pointer-events-none h-full w-full"
|
||
draggable={false}
|
||
onDragStart={preventNativeImageDrag}
|
||
src={src}
|
||
style={{
|
||
objectFit: normalized.fit,
|
||
objectPosition: normalized.objectPosition,
|
||
transform: `scale(${normalized.scale}) translate(${normalized.translateX}%, ${normalized.translateY}%)`,
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function NineGridCompositePreview({ node, sourceUrl }: { node: CloneNode; sourceUrl: string }) {
|
||
const rows = Math.max(1, Math.round(Number(node.draftValues?.gridRows || 3)));
|
||
const columns = Math.max(1, Math.round(Number(node.draftValues?.gridColumns || 3)));
|
||
const cellCount = rows * columns;
|
||
const visuals = Array.isArray(node.draftValues?.nineGridCells)
|
||
? (node.draftValues?.nineGridCells as GridCellVisual[])
|
||
: [];
|
||
|
||
return (
|
||
<div
|
||
className="absolute inset-0 grid bg-[#161616]"
|
||
style={{
|
||
gap: 1.5,
|
||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
|
||
}}
|
||
>
|
||
{Array.from({ length: cellCount }).map((_, index) => (
|
||
<div className="relative overflow-hidden bg-white" key={`${node.id}-grid-cell-${index}`}>
|
||
{sourceUrl ? (
|
||
<GridCellImage
|
||
alt={`${node.title} ${index + 1}`}
|
||
src={sourceUrl}
|
||
visual={visuals[index] || getGridCellVisual(index)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CroppedGridTileImage({
|
||
alt,
|
||
cropTile,
|
||
rotation,
|
||
src,
|
||
}: {
|
||
alt: string;
|
||
cropTile: NonNullable<ReturnType<typeof normalizeGridCropTile>>;
|
||
rotation: number;
|
||
src: string;
|
||
}) {
|
||
const { rect } = cropTile;
|
||
return (
|
||
<div className="absolute inset-0 overflow-hidden bg-white" style={{ transform: `rotate(${rotation}deg)` }}>
|
||
<img
|
||
alt={alt}
|
||
className="pointer-events-none absolute max-w-none"
|
||
draggable={false}
|
||
onDragStart={preventNativeImageDrag}
|
||
src={src}
|
||
style={{
|
||
height: `${100 / rect.height}%`,
|
||
left: `${-(rect.x / rect.width) * 100}%`,
|
||
objectFit: "fill",
|
||
top: `${-(rect.y / rect.height) * 100}%`,
|
||
width: `${100 / rect.width}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function OfficialAudioCard({ mediaUrl, selected }: { mediaUrl: string; selected: boolean }) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-[#f5f6f9] text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||
selected && "outline outline-2 outline-white/70"
|
||
)}
|
||
>
|
||
<div className="absolute left-3 right-3 top-3 h-[74px] overflow-hidden rounded-[8px] bg-[#343434]">
|
||
<span className="absolute left-0 top-0 h-full w-[3px] bg-[#f23a52]" />
|
||
<div className="absolute bottom-3 left-4 right-4 flex h-[42px] items-end gap-[5px]">
|
||
{Array.from({ length: 34 }).map((_, index) => (
|
||
<span
|
||
className="w-[4px] rounded-full bg-white/24"
|
||
key={index}
|
||
style={{ height: `${12 + ((index * 13) % 30)}px` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="absolute bottom-4 left-3 flex items-center gap-3 text-[15px] text-black/62">
|
||
<span>00:00 / 00:03</span>
|
||
</div>
|
||
<button
|
||
className="absolute bottom-3 left-1/2 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full bg-[#303030] text-white/78 transition hover:bg-black"
|
||
onClick={() => toast.info("音频播放控制待接入。")}
|
||
type="button"
|
||
>
|
||
<Play className="ml-0.5 h-4 w-4 fill-current" strokeWidth={0} />
|
||
</button>
|
||
{mediaUrl ? (
|
||
<a
|
||
className="absolute right-3 top-4 flex h-7 w-7 items-center justify-center rounded-[6px] bg-black/18 text-[14px] text-white/78 hover:bg-black/28"
|
||
href={mediaUrl}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
rel="noreferrer"
|
||
target="_blank"
|
||
>
|
||
↥
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CanvasNodeShell({
|
||
children,
|
||
node,
|
||
onContextMenu,
|
||
onOpenConnectionMenu,
|
||
onPointerDown,
|
||
selected,
|
||
}: {
|
||
children: ReactNode;
|
||
node: CloneNode;
|
||
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
|
||
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
|
||
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
||
selected: boolean;
|
||
}) {
|
||
const targetModuleKind = getTargetModuleKind(node);
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"absolute cursor-grab select-none active:cursor-grabbing",
|
||
targetModuleKind ? "shadow-none" : "shadow-[0_14px_34px_rgba(0,0,0,0.35)]",
|
||
selected && "z-20"
|
||
)}
|
||
data-canvas-node="true"
|
||
data-canvas-overlay="true"
|
||
onContextMenu={onContextMenu}
|
||
onPointerDown={onPointerDown}
|
||
style={{ height: node.height, left: node.x, top: node.y, width: node.width }}
|
||
>
|
||
{targetModuleKind ? (
|
||
<>
|
||
<button
|
||
className="absolute left-[-13px] top-1/2 z-10 mt-[14px] flex h-6 w-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-white/18 bg-[#242424] text-[20px] leading-none text-white/50 hover:border-white/34 hover:text-white/82"
|
||
onClick={(event) => onOpenConnectionMenu(event, "target")}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
title="添加上下文"
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
className="absolute right-[-13px] top-1/2 z-10 mt-[14px] flex h-6 w-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border border-white/18 bg-[#242424] text-[20px] leading-none text-white/50 hover:border-white/34 hover:text-white/82"
|
||
onClick={(event) => onOpenConnectionMenu(event, "source")}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
title="引用该节点生成"
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
className="absolute left-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 cursor-pointer rounded-full border border-white/30 bg-white text-[16px] leading-[16px] text-black/45 hover:text-black"
|
||
onClick={(event) => onOpenConnectionMenu(event, "target")}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
title="添加上下文"
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
className="absolute right-[-10px] top-1/2 z-10 h-5 w-5 -translate-y-1/2 cursor-pointer rounded-full border border-white/30 bg-white text-[16px] leading-[16px] text-black/45 hover:text-black"
|
||
onClick={(event) => onOpenConnectionMenu(event, "source")}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
title="引用该节点生成"
|
||
type="button"
|
||
>
|
||
+
|
||
</button>
|
||
</>
|
||
)}
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PresetMediaCanvasNode({ node, selected }: { node: CloneNode; selected: boolean }) {
|
||
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
|
||
const mediaUrl = getNodeMediaUrl(node);
|
||
const sourceUrl = getNodeSourceMediaUrl(node);
|
||
const generated = Boolean(node.draftValues?.generated);
|
||
const annotationMode = Boolean(node.draftValues?.annotationMode);
|
||
const rotation = Number(node.draftValues?.rotation || 0);
|
||
const outputSizeLabel = getDraftString(node, "outputSizeLabel");
|
||
const isNineGridComposite = Boolean(node.draftValues?.nineGridComposite);
|
||
const cropTile = normalizeGridCropTile(node.draftValues?.cropTile);
|
||
const gridCellVisual = node.draftValues?.gridCellVisual
|
||
? normalizeGridCellVisual(node.draftValues?.gridCellVisual)
|
||
: null;
|
||
const Icon = mediaType === "audio" ? Music2 : mediaType === "video" ? Video : ImageIcon;
|
||
|
||
return (
|
||
<div className="h-full w-full">
|
||
<div className="mb-2 flex h-6 items-center justify-between gap-3 text-[20px] font-semibold text-white/50">
|
||
<span className="flex min-w-0 items-center gap-1">
|
||
<Icon className="h-5 w-5 shrink-0" />
|
||
<span className="truncate">{node.title}</span>
|
||
</span>
|
||
{outputSizeLabel ? (
|
||
<span className="shrink-0 text-[14px] font-medium text-white/34">{outputSizeLabel}</span>
|
||
) : null}
|
||
</div>
|
||
{mediaType === "audio" ? (
|
||
<OfficialAudioCard mediaUrl={mediaUrl} selected={selected} />
|
||
) : mediaType === "video" ? (
|
||
<div
|
||
className={cn(
|
||
"relative flex h-[calc(100%-32px)] items-center justify-center overflow-hidden rounded-[10px] border border-white/12 bg-[#101010] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||
selected && "outline outline-2 outline-[#8b96ff]"
|
||
)}
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.12)_1px,transparent_1px)] bg-[length:18px_18px] opacity-20" />
|
||
{mediaUrl ? (
|
||
<video className="relative h-full w-full object-cover" controls playsInline src={mediaUrl} />
|
||
) : (
|
||
<div className="relative text-center">
|
||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-white/8 text-white/70">
|
||
<Video className="h-7 w-7" />
|
||
</div>
|
||
<div className="mt-4 text-[18px] font-semibold text-white/78">待生成视频</div>
|
||
<div className="mt-2 text-[13px] text-white/38">生成后将在这里预览</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
className="absolute right-3 top-3 rounded-[8px] bg-white/8 px-3 py-2 text-[12px] text-white/72 hover:bg-white/12"
|
||
onClick={() => toast.info("视频全屏预览待接入。")}
|
||
type="button"
|
||
>
|
||
↗
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={cn(
|
||
"relative h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-white shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||
selected && "outline outline-2 outline-[#8b96ff]"
|
||
)}
|
||
>
|
||
{isNineGridComposite ? (
|
||
<NineGridCompositePreview node={node} sourceUrl={sourceUrl || mediaUrl} />
|
||
) : cropTile && mediaUrl && gridCellVisual ? (
|
||
<GridCellImage alt={node.title} src={mediaUrl} visual={gridCellVisual} />
|
||
) : cropTile && mediaUrl ? (
|
||
<CroppedGridTileImage alt={node.title} cropTile={cropTile} rotation={rotation} src={mediaUrl} />
|
||
) : mediaUrl ? (
|
||
<img
|
||
alt={node.title}
|
||
className={cn("pointer-events-none h-full w-full", node.title === "角色三视图" ? "object-contain" : "object-cover")}
|
||
draggable={false}
|
||
onDragStart={preventNativeImageDrag}
|
||
src={mediaUrl}
|
||
style={{ transform: `rotate(${rotation}deg)` }}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center text-[13px] text-black/40">等待图片</div>
|
||
)}
|
||
{annotationMode ? (
|
||
<div className="pointer-events-none absolute inset-0">
|
||
<span className="absolute left-[38%] top-[32%] h-[34%] w-[24%] rounded-[8px] border border-white/85 bg-black/10 shadow-[0_0_0_1px_rgba(0,0,0,0.3)]" />
|
||
<span className="absolute left-[38%] top-[calc(32%-26px)] rounded-[7px] bg-black/68 px-2 py-1 text-[11px] text-white">主体</span>
|
||
</div>
|
||
) : null}
|
||
{!generated ? (
|
||
<button
|
||
className="absolute right-3 top-3 flex h-10 w-10 items-center justify-center rounded-[8px] bg-black/56 text-white/90"
|
||
onClick={() => toast.info(`${node.title}素材替换接口待接入。`)}
|
||
type="button"
|
||
>
|
||
↥
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PresetScriptCanvasNode({
|
||
node,
|
||
onTextResourceChange,
|
||
selected,
|
||
}: {
|
||
node: CloneNode;
|
||
onTextResourceChange: (content: string) => void;
|
||
selected: boolean;
|
||
}) {
|
||
const scriptRole = getDraftString(node, "scriptRole");
|
||
const contentValues = node.draftValues?.content;
|
||
const textContent =
|
||
Array.isArray(contentValues) && typeof contentValues[0] === "string"
|
||
? contentValues[0]
|
||
: node.draftValues?.manualEditing
|
||
? ""
|
||
: STORY_SCRIPT_TEXT;
|
||
const rows = [
|
||
["01", "现代办公室", "深夜加班,键盘声急促,人物体力透支", "环境键盘声"],
|
||
["02", "盛唐大殿", "女主惊醒跪于金銮殿,百官压迫", "鼓声与低频"],
|
||
["03", "长安街市", "诗声落下,盛唐山河展开", "史诗音乐起"],
|
||
];
|
||
|
||
return (
|
||
<div className="h-full w-full">
|
||
<div className="mb-2 flex h-6 items-center gap-1 text-[20px] font-semibold text-white/50">
|
||
{scriptRole === "generator" ? <GridTilesGlyph /> : <FileText className="h-5 w-5" />}
|
||
{node.title}
|
||
</div>
|
||
{scriptRole === "generator" ? (
|
||
<div
|
||
className={cn(
|
||
"h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-white text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||
selected && "outline outline-2 outline-[#8b96ff]"
|
||
)}
|
||
>
|
||
<div className="flex h-12 items-center justify-between border-[#e5e7eb] border-b px-4">
|
||
<span className="font-semibold text-[15px]">故事脚本</span>
|
||
<span className="rounded-full bg-[#eef0ff] px-3 py-1 text-[12px] text-[#5161ff]">待生成</span>
|
||
</div>
|
||
<div className="grid grid-cols-[64px_120px_1fr_110px] border-[#eef0f3] border-b bg-[#f7f8fa] text-[12px] text-[#697080]">
|
||
{["镜号", "场景", "画面", "声音"].map((item) => (
|
||
<div className="border-[#eef0f3] border-r px-3 py-2 last:border-r-0" key={item}>{item}</div>
|
||
))}
|
||
</div>
|
||
{rows.map((row) => (
|
||
<div className="grid grid-cols-[64px_120px_1fr_110px] border-[#eef0f3] border-b text-[12px]" key={row[0]}>
|
||
{row.map((cell) => (
|
||
<div className="border-[#eef0f3] border-r px-3 py-3 last:border-r-0" key={cell}>{cell}</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
<button
|
||
className="m-4 h-10 w-[calc(100%-32px)] rounded-[10px] bg-[#111] text-[13px] font-semibold text-white"
|
||
onClick={() => toast.info("故事脚本生成接口待接入。")}
|
||
type="button"
|
||
>
|
||
生成完整脚本
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={cn(
|
||
"h-[calc(100%-32px)] overflow-hidden rounded-[10px] border border-white/12 bg-[#f4f5f7] text-[#1f2329] shadow-[0_18px_60px_rgba(0,0,0,0.36)]",
|
||
selected && "outline outline-2 outline-[#8b96ff]"
|
||
)}
|
||
>
|
||
<div className="flex h-11 items-center justify-between border-[#dfe2e8] border-b px-4 text-[13px] text-[#697080]">
|
||
<span>输入内容</span>
|
||
<button className="rounded-[8px] px-2 py-1 text-[#4f5cff] hover:bg-black/5" onClick={() => toast.info("编辑剧本接口待接入。")} type="button">
|
||
编辑
|
||
</button>
|
||
</div>
|
||
<textarea
|
||
className="h-[calc(100%-44px)] w-full resize-none bg-transparent px-5 py-4 text-[14px] leading-7 outline-none placeholder:text-black/38"
|
||
onChange={(event) => onTextResourceChange(event.target.value)}
|
||
onPointerDown={(event) => event.stopPropagation()}
|
||
onWheel={(event) => event.stopPropagation()}
|
||
placeholder="输入内容"
|
||
value={textContent}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AddModulePanel({
|
||
onAddModule,
|
||
onClose,
|
||
onGallerySelect,
|
||
onUploadFiles,
|
||
}: {
|
||
onAddModule: (kind: AddModuleKind) => void;
|
||
onClose: () => void;
|
||
onGallerySelect: () => void;
|
||
onUploadFiles: (files: File[]) => void;
|
||
}) {
|
||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
||
const triggerUpload = () => {
|
||
uploadInputRef.current?.click();
|
||
};
|
||
|
||
return (
|
||
<section
|
||
className="fixed left-[88px] top-[300px] z-50 flex w-[240px] flex-col gap-2 rounded-[16px] border border-white/10 bg-[#202020]/96 p-2 text-white shadow-[0_24px_80px_rgba(0,0,0,0.52)] backdrop-blur-[32px]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<input
|
||
accept="image/*,video/*,audio/*"
|
||
className="hidden"
|
||
multiple
|
||
onChange={(event) => {
|
||
const files = Array.from(event.target.files || []);
|
||
if (files.length > 0) onUploadFiles(files);
|
||
event.currentTarget.value = "";
|
||
}}
|
||
ref={uploadInputRef}
|
||
type="file"
|
||
/>
|
||
<div className="flex items-center justify-between px-2 py-1">
|
||
<h4 className="text-[14px] font-medium leading-5 text-white/60">添加节点</h4>
|
||
<button className="flex h-7 w-7 items-center justify-center rounded-[8px] text-white/42 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
{ADD_NODE_MODULES.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<button
|
||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||
key={item.kind}
|
||
onClick={() => onAddModule(item.kind)}
|
||
type="button"
|
||
>
|
||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||
<Icon className="h-5 w-5" />
|
||
</span>
|
||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||
<span className="flex items-center gap-1.5 text-[14px] font-medium leading-5">
|
||
{item.label}
|
||
{item.badge ? (
|
||
<span className="rounded-[4px] bg-white/10 px-1.5 py-0.5 text-[10px] leading-3 text-white/50">
|
||
{item.badge}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||
{item.description}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
<h4 className="px-2 py-1 text-[14px] font-medium leading-5 text-white/60">添加资源</h4>
|
||
<button
|
||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||
onClick={triggerUpload}
|
||
type="button"
|
||
>
|
||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||
<Upload className="h-5 w-5" />
|
||
</span>
|
||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||
<span className="text-[14px] font-medium leading-5">上传</span>
|
||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||
可上传图片、视频、音频文件
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
<button
|
||
className="group flex h-[52px] w-full cursor-pointer items-center gap-2 rounded-[12px] px-2 py-2 text-left text-white/82 transition-colors duration-200 hover:bg-white/8"
|
||
onClick={onGallerySelect}
|
||
type="button"
|
||
>
|
||
<span className="flex h-[34px] w-[34px] shrink-0 items-center justify-center rounded-[8px] bg-white/8 text-white/82">
|
||
<Layers className="h-5 w-5" />
|
||
</span>
|
||
<span className="h-full min-w-0 flex-1 overflow-hidden">
|
||
<span className="flex h-full translate-y-2 flex-col justify-start transition-transform duration-200 group-hover:translate-y-0">
|
||
<span className="text-[14px] font-medium leading-5">从图库选择</span>
|
||
<span className="mt-0.5 truncate text-[12px] leading-4 opacity-0 transition-opacity duration-200 group-hover:opacity-60">
|
||
从历史生成中选择素材
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function WorkflowPanel({
|
||
onAdd,
|
||
onClose,
|
||
workflows,
|
||
}: {
|
||
onAdd: (workflow: WorkflowPreset, index: number) => void;
|
||
onClose: () => void;
|
||
workflows: WorkflowPreset[];
|
||
}) {
|
||
const visibleWorkflows: WorkflowPreset[] = workflows.length > 0
|
||
? workflows.slice(0, 9)
|
||
: STARTER_CARDS.map<WorkflowPreset>((card, index) => ({
|
||
cover: card.coverUrl,
|
||
description: card.subtitle,
|
||
id: index,
|
||
name: `【预设】${card.title}`,
|
||
}));
|
||
|
||
return (
|
||
<section
|
||
className="absolute left-[132px] top-[72px] z-50 flex h-[min(794px,calc(100vh-96px))] w-[960px] flex-col overflow-hidden rounded-[18px] border border-white/10 bg-[#202020] shadow-[0_28px_90px_rgba(0,0,0,0.48)]"
|
||
data-canvas-overlay="true"
|
||
>
|
||
<div className="flex h-[96px] items-center justify-between border-white/8 border-b px-8">
|
||
<div className="flex items-center gap-3">
|
||
<div className="font-semibold text-[26px] text-white">我的工具箱</div>
|
||
<button className="flex h-7 w-7 items-center justify-center rounded-full border border-white/22 text-[18px] text-white/52" onClick={() => toast.info("工具箱说明待接入。")} type="button">
|
||
?
|
||
</button>
|
||
</div>
|
||
<button className="rounded-[8px] p-2 text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-9 w-9" strokeWidth={1.6} />
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
|
||
<div className="grid grid-cols-3 gap-x-6 gap-y-8">
|
||
{visibleWorkflows.map((workflow, index) => (
|
||
<button
|
||
className="group text-left"
|
||
key={`${workflow.id || ""}-${workflow.uuid || workflow.name}`}
|
||
onClick={() => onAdd(workflow, index)}
|
||
type="button"
|
||
>
|
||
<div className="relative h-[282px] overflow-hidden rounded-[8px] bg-[#2a2a2a]">
|
||
{workflow.cover ? (
|
||
<img
|
||
alt=""
|
||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.025]"
|
||
src={workflow.cover}
|
||
/>
|
||
) : (
|
||
<div className="flex h-full items-center justify-center bg-white/6 text-white/24">
|
||
<WorkflowGlyph />
|
||
</div>
|
||
)}
|
||
<span className="absolute inset-0 bg-black/0 transition group-hover:bg-black/8" />
|
||
</div>
|
||
<div className="mt-3 px-1">
|
||
<div className="line-clamp-2 text-[22px] font-medium leading-7 text-white/76">{workflow.name || workflow.title || "未命名预设"}</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 = Boolean(tool && requiresMedia(tool));
|
||
const inspectorTitle = getInspectorTitle(node);
|
||
const primaryTab = node.inputType || type || node.kind;
|
||
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]">{inspectorTitle}</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">
|
||
{primaryTab}
|
||
</span>
|
||
<span className="rounded-[6px] bg-[#5b5220] px-2 py-1 text-[12px] text-[#d7c66a]">后端待接入</span>
|
||
{tool?.vendor ? <span className="rounded-[6px] bg-white/8 px-2 py-1 text-[12px] text-white/48">{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">用于接口调用、模型能力和自动化任务。</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 SkillsAccessModal({ onClose }: { onClose: () => void }) {
|
||
return (
|
||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/58" data-canvas-overlay="true">
|
||
<div className="w-[600px] max-w-[calc(100vw-32px)] rounded-[8px] border border-white/10 bg-[#262626] px-4 py-5 text-white shadow-[0_30px_100px_rgba(0,0,0,0.58)]">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="min-w-0">
|
||
<h2 className="text-[15px] font-semibold leading-none text-white">我的Access key</h2>
|
||
<div className="mt-5 space-y-2 text-[13px] leading-none text-white/76">
|
||
<p>· Access key 仅用于 LibTV skills 的调用授权使用,一旦传播给他人,有账号泄露的风险,请谨慎保管!</p>
|
||
<p>· LibTVskills :是Libtv提供给agent使用的入口,可在openclaw中调用,完成专业视频创作。</p>
|
||
<p>
|
||
· 安装使用请戳
|
||
<button
|
||
className="ml-1 text-[#2f8bff] underline-offset-2 hover:underline"
|
||
onClick={() => toast.info("接口文档入口待接入。")}
|
||
type="button"
|
||
>
|
||
接口文档
|
||
</button>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button className="rounded-[8px] p-2 text-white/54 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-5 w-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-end">
|
||
<button
|
||
className="h-8 rounded-[8px] bg-white px-4 text-[12px] font-semibold text-black hover:bg-white/90"
|
||
onClick={() => toast.info("Access key 创建接口待接入。")}
|
||
type="button"
|
||
>
|
||
创建新Access key
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 overflow-hidden rounded-[8px] border border-white/8">
|
||
<div className="grid h-11 grid-cols-[240px_1fr] bg-white/16 text-[13px] text-white/86">
|
||
<div className="flex items-center px-4">创建日期</div>
|
||
<div className="flex items-center px-4">Access key</div>
|
||
</div>
|
||
<div className="flex h-[66px] items-center px-4 text-[13px] text-white/45">暂无 Access key</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SharePopover({
|
||
onClose,
|
||
onCopyLink,
|
||
onPublish,
|
||
}: {
|
||
onClose: () => void;
|
||
onCopyLink: () => void;
|
||
onPublish: () => void;
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-0 z-[70]" data-canvas-overlay="true">
|
||
<button
|
||
aria-label="关闭分享菜单"
|
||
className="absolute inset-0 cursor-default bg-transparent"
|
||
onClick={onClose}
|
||
type="button"
|
||
/>
|
||
<div className="fixed right-[312px] top-[66px] z-[75] w-[360px] rounded-[16px] border border-white/10 bg-[#202020]/98 p-2 text-white shadow-[0_24px_80px_rgba(0,0,0,0.52)] backdrop-blur-[16px]">
|
||
<div className="flex flex-col gap-2">
|
||
<span className="pl-2 text-sm font-medium text-white/60">发布与分享</span>
|
||
<button
|
||
className="flex cursor-pointer items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-white/8"
|
||
onClick={onPublish}
|
||
type="button"
|
||
>
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white/10 text-white/80">
|
||
<StoreGlyph />
|
||
</span>
|
||
<span className="flex min-w-0 flex-col gap-1">
|
||
<span className="text-sm font-medium text-white">在LibTV上发布</span>
|
||
<span className="text-xs leading-snug text-white/70">发布你的作品和创作过程,让更多创作者看到。</span>
|
||
</span>
|
||
</button>
|
||
<button
|
||
className="flex cursor-pointer items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-white/8"
|
||
onClick={onCopyLink}
|
||
type="button"
|
||
>
|
||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white/10 text-white/80">
|
||
<Copy className="h-5 w-5" />
|
||
</span>
|
||
<span className="flex min-w-0 flex-col gap-1">
|
||
<span className="text-sm font-medium text-white">分享链接</span>
|
||
<span className="text-xs leading-snug text-white/70">拥有此链接的人可以查看并复制你的画布。</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MemberStoreModal({ onClose }: { onClose: () => void }) {
|
||
const [activeTab, setActiveTab] = useState<MemberStoreTab>("model");
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/62 backdrop-blur-[3px]" data-canvas-overlay="true">
|
||
<button aria-label="关闭积分中心" className="absolute inset-0 cursor-default" onClick={onClose} type="button" />
|
||
<div className="relative flex max-h-[86dvh] w-[980px] max-w-[calc(100vw-32px)] overflow-hidden rounded-[16px] border border-white/12 bg-[#1f1f1f] text-white shadow-[0_30px_100px_rgba(0,0,0,0.62)]">
|
||
<div className="w-[244px] shrink-0 border-white/8 border-r bg-[#171717] p-5">
|
||
<div className="flex items-center gap-2">
|
||
<span className="flex h-8 w-8 items-center justify-center rounded-[8px] bg-[#05dff6]/12 text-[#05dff6]">
|
||
<StoreGlyph />
|
||
</span>
|
||
<div>
|
||
<div className="text-[18px] font-semibold">积分中心</div>
|
||
<div className="mt-0.5 text-[12px] text-white/42">积分套餐与模型资源</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-5 rounded-[12px] border border-[#ffc65d]/20 bg-[#2a2418] p-3">
|
||
<div className="text-[12px] text-[#fad6a4]">积分余额</div>
|
||
<div className="mt-1 text-[22px] font-bold leading-tight">可用积分套餐</div>
|
||
<div className="mt-2 text-[12px] leading-5 text-white/52">会员专属模型超市,顶级模型囤货必选。</div>
|
||
</div>
|
||
<div className="mt-4 space-y-1">
|
||
{[
|
||
["vip", "会员特惠", "每月积分与专属权益"],
|
||
["model", "模型积分卡", "限时闪购 买一赠一"],
|
||
["credits", "积分充值", "通用积分灵活补充"],
|
||
].map(([id, title, desc]) => (
|
||
<button
|
||
className={cn(
|
||
"w-full rounded-[10px] px-3 py-2 text-left transition-colors",
|
||
activeTab === id ? "bg-white text-black" : "text-white/66 hover:bg-white/8 hover:text-white"
|
||
)}
|
||
key={id}
|
||
onClick={() => setActiveTab(id as MemberStoreTab)}
|
||
type="button"
|
||
>
|
||
<span className="block text-[13px] font-semibold">{title}</span>
|
||
<span className={cn("mt-1 block text-[11px]", activeTab === id ? "text-black/55" : "text-white/36")}>{desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-5 rounded-[8px] border border-white/8 bg-white/5 p-3 text-[12px] leading-5 text-white/48">
|
||
免费用户登录每日赠送20积分,每日赠2次5折视频;云端存储空间3GB。
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
<div className="flex h-[64px] items-center justify-between border-white/8 border-b px-5">
|
||
<div>
|
||
<div className="text-[17px] font-semibold">
|
||
{activeTab === "model" ? "模型积分卡" : activeTab === "vip" ? "会员特惠" : "积分充值"}
|
||
</div>
|
||
<div className="mt-1 text-[12px] text-white/42">
|
||
{activeTab === "model" ? "模型超市内积分有效期为6个月,支付后不退不换。" : activeTab === "vip" ? "购买后立即生效,积分按月自动发放。" : "积分有效期2年,支付后不退不换。"}
|
||
</div>
|
||
</div>
|
||
<button className="flex h-8 w-8 items-center justify-center rounded-[8px] text-white/50 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="overflow-y-auto p-5">
|
||
{activeTab === "model" ? <ModelCardGrid /> : null}
|
||
{activeTab === "vip" ? <VipPlanGrid /> : null}
|
||
{activeTab === "credits" ? <CreditPackGrid /> : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ModelCardGrid() {
|
||
return (
|
||
<div>
|
||
<div className="mb-4 flex items-center justify-between rounded-[10px] border border-[#ffc65d]/20 bg-[#2a2418] px-4 py-3">
|
||
<div>
|
||
<div className="text-[14px] font-semibold text-[#ffdca8]">限时闪购 买一赠一</div>
|
||
<div className="mt-1 text-[12px] text-white/48">会员专属模型超市,顶级模型囤货必选</div>
|
||
</div>
|
||
<button
|
||
className="h-9 rounded-[8px] bg-[#ffc65d] px-4 text-[13px] font-semibold text-black hover:bg-[#ffd282]"
|
||
onClick={() => toast.info("模型积分卡购买接口待接入。")}
|
||
type="button"
|
||
>
|
||
查看规则
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{MEMBER_MODEL_CARDS.map((card) => (
|
||
<MemberModelCard card={card} key={`${card.title}-${card.power}`} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MemberModelCard({ card }: { card: (typeof MEMBER_MODEL_CARDS)[number] }) {
|
||
const theme = MEMBER_CARD_THEMES[card.group] || MEMBER_CARD_THEMES["seedance-2"];
|
||
|
||
return (
|
||
<button
|
||
className="group relative min-h-[190px] overflow-hidden rounded-[12px] border border-white/8 p-4 text-left shadow-[0_14px_36px_rgba(0,0,0,0.24)] transition-colors hover:border-white/18"
|
||
onClick={() => toast.info(`${card.title} 购买接口待接入。`)}
|
||
style={{
|
||
backgroundColor: theme.tone,
|
||
backgroundImage: `linear-gradient(180deg, rgba(255,255,255,0.06), rgba(0,0,0,0.12)), url(${theme.bg})`,
|
||
backgroundPosition: "right top",
|
||
backgroundRepeat: "no-repeat",
|
||
backgroundSize: "170px auto",
|
||
}}
|
||
type="button"
|
||
>
|
||
<span className="absolute right-3 top-3 rounded-[8px] bg-[#ff4d4f] px-2 py-1 text-[10px] font-semibold text-white">{card.corner}</span>
|
||
<span className="flex h-8 w-8 items-center justify-center rounded-[8px] bg-white/14">
|
||
<img alt="" className="h-5 w-5" src={theme.icon} />
|
||
</span>
|
||
<span className="mt-5 block text-[16px] font-semibold text-white">{card.title}</span>
|
||
<span className="mt-1 block text-[12px] text-white/55">{card.label}</span>
|
||
<span className="mt-4 flex items-baseline gap-1">
|
||
<span className="text-[24px] font-bold text-white">{card.power}</span>
|
||
<span className="text-[12px] text-white/50">积分</span>
|
||
</span>
|
||
<span className="mt-3 flex items-center justify-between">
|
||
<span className="text-[11px] text-white/42">{card.desc}</span>
|
||
<span className="rounded-[8px] bg-white px-3 py-1.5 text-[12px] font-semibold text-black transition group-hover:bg-white/90">¥{card.price}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function VipPlanGrid() {
|
||
return (
|
||
<div className="grid grid-cols-3 gap-3">
|
||
{VIP_PLAN_CARDS.map((plan, index) => (
|
||
<div className="rounded-[12px] border border-white/8 bg-white/[0.06] p-4" key={plan.title}>
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-[16px] font-semibold">{plan.title}</span>
|
||
{index === 1 ? <span className="rounded-full bg-[#ffc65d] px-2 py-0.5 text-[10px] font-semibold text-black">推荐</span> : null}
|
||
</div>
|
||
<div className="mt-2 text-[12px] text-white/48">{plan.power}</div>
|
||
<div className="mt-5 flex items-end gap-1">
|
||
<span className="text-[13px] text-[#ffc65d]">¥</span>
|
||
<span className="text-[32px] font-bold text-[#ffc65d]">{plan.price}</span>
|
||
<span className="pb-1 text-[12px] text-white/42">/月起</span>
|
||
</div>
|
||
<button
|
||
className="mt-4 h-9 w-full rounded-[8px] bg-white text-[13px] font-semibold text-black hover:bg-white/90"
|
||
onClick={() => toast.info(`${plan.title} 购买接口待接入。`)}
|
||
type="button"
|
||
>
|
||
立即开通
|
||
</button>
|
||
<div className="mt-4 space-y-2">
|
||
{plan.benefits.map((benefit) => (
|
||
<div className="flex items-center gap-2 text-[12px] text-white/58" key={benefit}>
|
||
<span className="h-1.5 w-1.5 rounded-full bg-[#ffc65d]" />
|
||
{benefit}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CreditPackGrid() {
|
||
return (
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{CREDIT_PACKS.map((pack) => (
|
||
<button
|
||
className="rounded-[12px] border border-white/8 bg-white/[0.06] p-4 text-left transition hover:border-white/18 hover:bg-white/[0.08]"
|
||
key={pack.points}
|
||
onClick={() => toast.info(`${pack.points} 积分充值接口待接入。`)}
|
||
type="button"
|
||
>
|
||
<span className="rounded-full bg-[#ff4d4f]/18 px-2 py-0.5 text-[10px] font-semibold text-[#ff8c8c]">{pack.tag}</span>
|
||
<span className="mt-5 block text-[26px] font-bold">{pack.points}</span>
|
||
<span className="mt-1 block text-[12px] text-white/45">通用积分</span>
|
||
<span className="mt-5 block text-[15px] font-semibold text-[#ffc65d]">¥{pack.price}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NotificationsPopover({ onClose }: { onClose: () => void }) {
|
||
return (
|
||
<div className="fixed inset-0 z-[70]" data-canvas-overlay="true">
|
||
<button aria-label="关闭通知" className="absolute inset-0 cursor-default bg-transparent" onClick={onClose} type="button" />
|
||
<div className="fixed right-[404px] top-[66px] z-[75] w-[360px] overflow-hidden rounded-[16px] border border-white/10 bg-[#202020]/98 text-white shadow-[0_24px_80px_rgba(0,0,0,0.52)] backdrop-blur-[16px]">
|
||
<div className="flex items-center justify-between border-white/8 border-b px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<span className="relative flex h-8 w-8 items-center justify-center rounded-[8px] bg-white/8">
|
||
<span className="absolute right-1 top-1 h-[5px] w-[5px] rounded-full bg-[#f53f3f]" />
|
||
<Bell className="h-4 w-4 text-white/76" />
|
||
</span>
|
||
<div>
|
||
<div className="text-[14px] font-semibold">通知</div>
|
||
<div className="text-[11px] text-white/40">生成任务与系统消息</div>
|
||
</div>
|
||
</div>
|
||
<button className="rounded-[8px] p-1.5 text-white/48 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="p-3">
|
||
<div className="mb-3 flex gap-2">
|
||
{["全部", "生成任务", "系统通知", "会员活动"].map((item, index) => (
|
||
<button
|
||
className={cn(
|
||
"h-7 rounded-[8px] px-3 text-[12px] transition-colors",
|
||
index === 0 ? "bg-white text-black" : "bg-white/6 text-white/56 hover:bg-white/10 hover:text-white"
|
||
)}
|
||
key={item}
|
||
type="button"
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{[
|
||
["生成任务", "任务状态、进度轮询和失败原因接口待接入。"],
|
||
["系统通知", "审核、账户和平台消息接口待接入。"],
|
||
["积分提醒", "积分套餐与模型资源入口待接入。"],
|
||
].map(([title, description]) => (
|
||
<button
|
||
className="mb-2 w-full rounded-[10px] border border-white/8 bg-white/5 p-3 text-left hover:bg-white/8"
|
||
key={title}
|
||
onClick={() => toast.info(`${title}接口待接入。`)}
|
||
type="button"
|
||
>
|
||
<span className="block text-[13px] font-semibold text-white/82">{title}</span>
|
||
<span className="mt-1 block text-[12px] leading-5 text-white/42">{description}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccountPopover({ onClose }: { onClose: () => void }) {
|
||
const { data: membershipSummary } = useSWR<MembershipSummaryResponse>("/api/user/membership-summary", fetcher);
|
||
const credits = formatCreditValue(Math.max(0, Number(membershipSummary?.summary?.currentCredits ?? 0)));
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-[70]" data-canvas-overlay="true">
|
||
<button aria-label="关闭账号菜单" className="absolute inset-0 cursor-default bg-transparent" onClick={onClose} type="button" />
|
||
<div className="fixed right-4 top-[66px] z-[75] w-[388px] max-w-[calc(100vw-32px)] overflow-hidden rounded-[16px] border border-white/10 bg-[#202020]/98 text-white shadow-[0_24px_80px_rgba(0,0,0,0.54)] backdrop-blur-[16px]">
|
||
<div className="px-4 pb-3 pt-5">
|
||
<div className="flex items-start gap-3">
|
||
<img alt="avatar" className="h-12 w-12 rounded-full object-cover" src={TARGET_AVATAR} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate text-[14px] font-bold text-white/90">微信用户82a0d8</span>
|
||
<span className="rounded-[7px] bg-[#09caf5]/12 px-2 py-0.5 text-[11px] text-[#09caf5]">个人</span>
|
||
</div>
|
||
<div className="mt-1 flex items-center gap-1 text-[12px] text-white/42">
|
||
<span>UUID</span>
|
||
<button
|
||
className="rounded-[6px] px-1 text-white/42 hover:bg-white/8 hover:text-white"
|
||
onClick={() => {
|
||
void navigator.clipboard?.writeText("c9d954c4006648a2825b7240bf046f96");
|
||
toast.success("ID 复制成功");
|
||
}}
|
||
type="button"
|
||
>
|
||
<Copy className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<ChevronDown className="mt-2 h-4 w-4 text-white/62" />
|
||
</div>
|
||
|
||
<div className="mt-4 rounded-[12px] border border-white/8 bg-white/[0.04] p-2">
|
||
<div className="flex items-center gap-1 px-2 py-1 text-[14px] font-medium text-white/45">
|
||
<UserRound className="h-4 w-4" />
|
||
个人空间
|
||
</div>
|
||
<button
|
||
className="flex w-full items-center justify-between rounded-[10px] p-2 text-left hover:bg-white/8"
|
||
onClick={() => toast.info("账号切换接口待接入。")}
|
||
type="button"
|
||
>
|
||
<span>
|
||
<span className="block text-[14px] font-medium text-white/86">个人空间</span>
|
||
<span className="mt-0.5 block text-[12px] text-white/48">个人免费版</span>
|
||
</span>
|
||
<span className="h-[14px] w-[14px] rounded-full border border-[#09caf5] bg-[#09caf5]/18" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||
<AccountMetricCard className="col-span-2" label="积分余额" value={credits} suffix="点" accent />
|
||
<AccountMetricCard label="加速余额" value="0" suffix="次" />
|
||
<AccountMetricCard label="存储空间" value="0G" suffix="/ 3G" />
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-1">
|
||
<AccountMenuButton icon={<UserRound className="h-[18px] w-[18px]" />} label="个人中心" onClick={() => toast.info("个人中心入口待接入。")} />
|
||
<AccountMenuButton icon={<Plus className="h-[18px] w-[18px]" />} label="创建团队" onClick={() => toast.info("创建团队接口待接入。")} />
|
||
<AccountMenuButton icon={<FileText className="h-[18px] w-[18px]" />} label="订阅与开发票" onClick={() => toast.info("订阅与开发票入口待接入。")} />
|
||
<AccountMenuButton icon={<Settings2 className="h-[18px] w-[18px]" />} label="账号设置" onClick={() => toast.info("账号设置入口待接入。")} />
|
||
<AccountMenuButton icon={<LogoutGlyph />} label="退出登录" onClick={() => toast.info("退出登录接口待接入。")} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccountMetricCard({
|
||
accent = false,
|
||
className,
|
||
label,
|
||
suffix,
|
||
value,
|
||
}: {
|
||
accent?: boolean;
|
||
className?: string;
|
||
label: string;
|
||
suffix: string;
|
||
value: string;
|
||
}) {
|
||
return (
|
||
<button
|
||
className={cn(
|
||
"rounded-[12px] border border-white/8 bg-white/[0.04] px-4 py-3 text-left hover:bg-white/[0.07]",
|
||
className
|
||
)}
|
||
onClick={() => toast.info(`${label}接口待接入。`)}
|
||
type="button"
|
||
>
|
||
<span className={cn("block text-[12px]", accent ? "text-[#09caf5]" : "text-white/45")}>{label}</span>
|
||
<span className="mt-1 flex items-baseline gap-1">
|
||
<span className={cn("font-['PlusJakartaSans'] text-[18px] font-bold", accent ? "text-[#09caf5]" : "text-white/88")}>{value}</span>
|
||
<span className="text-[12px] text-white/44">{suffix}</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function AccountMenuButton({
|
||
icon,
|
||
label,
|
||
onClick,
|
||
}: {
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
className="flex h-10 w-full cursor-pointer items-center gap-3 rounded-[10px] px-2 text-left text-[13px] text-white/82 transition-colors hover:bg-white/8"
|
||
onClick={onClick}
|
||
type="button"
|
||
>
|
||
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-white/50">{icon}</span>
|
||
<span className="flex-1">{label}</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function LogoutGlyph() {
|
||
return (
|
||
<svg aria-hidden="true" className="h-[18px] w-[18px]" fill="none" viewBox="0 0 18 18">
|
||
<path d="M9.75 2.925H4.5a.825.825 0 0 0-.825.825v10.5c0 .456.369.825.825.825h5.25v1.35H4.5a2.175 2.175 0 0 1-2.175-2.175V3.75c0-1.201.974-2.175 2.175-2.175h5.25zm5.658 5.482a.826.826 0 0 1 0 1.11l-3.658 4.025-.999-.908 2.722-2.996H5.25v-1.35h8.223L10.75 5.291l.999-.908z" fill="currentColor" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function SupportPanel() {
|
||
return (
|
||
<div className="space-y-3">
|
||
{[
|
||
["使用教程", "打开视频画布使用教程。"],
|
||
["联系开发者", "提交需求或联系开发者。"],
|
||
["优化反馈", "反馈画布、生成和支付问题。"],
|
||
].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 ImageNodePreview({ node }: { node: CloneNode }) {
|
||
const mediaType = getDraftString(node, "mediaType") || node.inputType || "image";
|
||
const url = getNodeMediaUrl(node);
|
||
const sourceUrl = getNodeSourceMediaUrl(node);
|
||
const rotation = Number(node.draftValues?.rotation || 0);
|
||
const isNineGridComposite = Boolean(node.draftValues?.nineGridComposite);
|
||
const cropTile = normalizeGridCropTile(node.draftValues?.cropTile);
|
||
const gridCellVisual = node.draftValues?.gridCellVisual
|
||
? normalizeGridCellVisual(node.draftValues?.gridCellVisual)
|
||
: null;
|
||
|
||
if (!url && !isNineGridComposite) {
|
||
return (
|
||
<div className="flex h-[420px] items-center justify-center rounded-[8px] border border-white/8 bg-white/5 text-[14px] text-white/42">
|
||
当前节点暂无可预览素材
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-[8px] border border-white/8 bg-[#111] p-3">
|
||
{mediaType === "video" ? (
|
||
<video className="max-h-[72vh] w-full rounded-[6px] object-contain" controls playsInline src={url} />
|
||
) : isNineGridComposite ? (
|
||
<div className="relative aspect-video max-h-[72vh] w-full overflow-hidden rounded-[6px] bg-white">
|
||
<NineGridCompositePreview node={node} sourceUrl={sourceUrl || url} />
|
||
</div>
|
||
) : cropTile && url && gridCellVisual ? (
|
||
<div className="relative aspect-square max-h-[72vh] w-full overflow-hidden rounded-[6px] bg-white">
|
||
<GridCellImage alt={node.title} src={url} visual={gridCellVisual} />
|
||
</div>
|
||
) : cropTile && url ? (
|
||
<div className="relative aspect-square max-h-[72vh] w-full overflow-hidden rounded-[6px] bg-white">
|
||
<CroppedGridTileImage alt={node.title} cropTile={cropTile} rotation={rotation} src={url} />
|
||
</div>
|
||
) : (
|
||
<img
|
||
alt={node.title}
|
||
className="max-h-[72vh] w-full rounded-[6px] object-contain"
|
||
src={url}
|
||
style={{ transform: `rotate(${rotation}deg)` }}
|
||
/>
|
||
)}
|
||
</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;
|
||
}) {
|
||
return (
|
||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-[4px]" data-canvas-overlay="true">
|
||
<div className="relative max-h-[min(90dvh,880px)] w-[480px] max-w-[calc(100vw-32px)] overflow-y-auto overflow-x-hidden rounded-[16px] border border-white/12 bg-[#242424] p-4 text-white shadow-[0_30px_100px_rgba(0,0,0,0.58)]">
|
||
<div
|
||
className="absolute -top-[60px] left-0 z-0 h-[214px] w-[503px] bg-cover bg-center bg-no-repeat"
|
||
style={{
|
||
backgroundImage: `linear-gradient(to bottom, rgba(0,0,0,0.25) 70%, #242424 100%), url(${PUBLISH_MODAL_BG_URL})`,
|
||
}}
|
||
/>
|
||
<button
|
||
className="absolute right-4 top-4 z-[2] flex h-8 w-8 cursor-pointer items-center justify-center rounded-full text-white/80 transition-colors hover:bg-black/35 hover:text-white"
|
||
onClick={onClose}
|
||
type="button"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
|
||
<div className="relative z-[1] flex flex-col gap-4">
|
||
<div className="mt-3 flex max-w-[420px] flex-col gap-2">
|
||
<p className="text-[16px] font-medium text-white drop-shadow-[0_1px_3px_rgba(0,0,0,0.85)]">
|
||
发布作品到LibTV
|
||
</p>
|
||
<p className="text-[14px] font-normal leading-normal text-white/85 drop-shadow-[0_1px_2px_rgba(0,0,0,0.75)]">
|
||
您的作品将展示到LibTV Show,方便大家交流使用
|
||
</p>
|
||
</div>
|
||
|
||
<div className="mt-6 flex gap-3">
|
||
<UploadBox label="选择视频" />
|
||
<UploadBox label="选择封面" hint="建议上传横版图片" />
|
||
</div>
|
||
|
||
<label className="block">
|
||
<span className="mb-1.5 block text-sm font-medium text-white/88">
|
||
作品名称 <span className="text-red-500">*</span>
|
||
</span>
|
||
<input
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-transparent px-3 text-[13px] text-white outline-none placeholder:text-white/35 focus:border-white/20"
|
||
defaultValue={projectTitle || "未命名"}
|
||
maxLength={120}
|
||
placeholder="请输入作品名称"
|
||
/>
|
||
</label>
|
||
|
||
<label className="block">
|
||
<span className="mb-1.5 block text-sm font-medium text-white/88">作品描述</span>
|
||
<textarea
|
||
className="min-h-[78px] w-full resize-none rounded-[8px] border border-white/10 bg-transparent px-3 py-2 text-[13px] text-white outline-none placeholder:text-white/35 focus:border-white/20"
|
||
maxLength={500}
|
||
placeholder="请输入作品描述"
|
||
/>
|
||
</label>
|
||
|
||
<div className="flex gap-3">
|
||
<label className="flex flex-1 flex-col gap-2">
|
||
<span className="text-sm font-medium text-white/88">活动标签</span>
|
||
<select className="h-10 rounded-[8px] border border-white/10 bg-transparent px-3 text-[13px] text-white/78 outline-none">
|
||
<option className="bg-[#242424]" value="0">不参与</option>
|
||
<option className="bg-[#242424]" value="1">LibTV Show</option>
|
||
</select>
|
||
</label>
|
||
<label className="flex flex-1 flex-col gap-2">
|
||
<span className="text-sm font-medium text-white/88">参赛赛道</span>
|
||
<select className="h-10 rounded-[8px] border border-white/10 bg-transparent px-3 text-[13px] text-white/45 outline-none">
|
||
<option className="bg-[#242424]" value="">请选择参赛单元</option>
|
||
<option className="bg-[#242424]" value="story">叙事短片</option>
|
||
<option className="bg-[#242424]" value="music">音乐影像</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<label className="block">
|
||
<span className="mb-1.5 flex items-center gap-1 text-sm font-medium text-white/88">
|
||
社媒链接
|
||
<HelpCircle className="h-3 w-3 text-white/48" />
|
||
</span>
|
||
<input
|
||
className="h-10 w-full rounded-[8px] border border-white/10 bg-transparent px-3 text-[13px] text-white outline-none placeholder:text-white/35 focus:border-white/20"
|
||
placeholder="请添加您在社媒发布该作品的链接"
|
||
/>
|
||
</label>
|
||
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[13px] text-white/86">公开画布</span>
|
||
<button
|
||
className={cn("relative h-5 w-9 rounded-full transition", publishPublic ? "bg-white" : "bg-white/18")}
|
||
onClick={onTogglePublic}
|
||
type="button"
|
||
>
|
||
<span className={cn("absolute top-[3px] h-3.5 w-3.5 rounded-full transition", publishPublic ? "left-[19px] bg-black" : "left-[3px] bg-white")} />
|
||
</button>
|
||
</div>
|
||
<button
|
||
className="cursor-pointer rounded-lg bg-[#478EFF] px-3 py-2 text-[13px] text-white transition-colors hover:bg-[#2563EB]"
|
||
onClick={() => toast.info("发布接口待接入。")}
|
||
type="button"
|
||
>
|
||
发布并投稿
|
||
</button>
|
||
</div>
|
||
|
||
<label className="mt-[-4px] flex cursor-pointer items-center gap-1">
|
||
<input className="h-3 w-3 accent-[#478EFF]" defaultChecked type="checkbox" />
|
||
<span className="text-[10px] text-white/60">
|
||
点击发布即代表同意
|
||
<a
|
||
className="text-[#478EFF] hover:underline"
|
||
href="https://www.liblib.art/activities/4b7ea7f1dbbf41669d9e05cd25a3fa77"
|
||
rel="noreferrer"
|
||
target="_blank"
|
||
>
|
||
《LibTV创作许可服务协议》
|
||
</a>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UploadBox({ hint, label }: { hint?: string; label: string }) {
|
||
return (
|
||
<button
|
||
className="flex h-[120px] flex-1 flex-col items-center justify-center rounded-[8px] border border-white/8 bg-white/12 text-white/72 transition-colors hover:bg-white/16 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-2 text-[12px] text-white/42">{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>
|
||
);
|
||
}
|