Refine video canvas function replica
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-19 08:52:38 +08:00
parent a4c1fbca39
commit 37b7e58f2a
7 changed files with 1280 additions and 58 deletions

View File

@ -0,0 +1,15 @@
import { redirect } from "next/navigation";
import { requireAdmin } from "@/lib/middleware/admin";
import { VideoCanvasSettingsClient } from "./video-canvas-settings-client";
export const dynamic = "force-dynamic";
export default async function VideoCanvasSettingsPage() {
try {
await requireAdmin();
} catch {
redirect("/");
}
return <VideoCanvasSettingsClient />;
}

View File

@ -0,0 +1,242 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils";
import {
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
type SettingsResponse = {
setting?: {
value?: unknown;
};
};
const functionKindLabels: Record<VideoCanvasFunctionDefinition["kind"], string> = {
audio: "音频",
image: "图片生成",
"image-edit": "图片编辑",
multimodal: "多模态",
utility: "工具",
video: "视频生成",
workflow: "工作流",
};
export function VideoCanvasSettingsClient() {
const { data, isLoading, mutate } = useSWR<SettingsResponse>(
`/api/admin/settings?key=${VIDEO_CANVAS_FUNCTION_CONFIG_KEY}`,
fetcher,
);
const [config, setConfig] = useState<VideoCanvasFunctionConfig>(defaultVideoCanvasFunctionConfig);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!data) return;
setConfig(normalizeVideoCanvasFunctionConfig(data.setting?.value));
}, [data]);
const functions = useMemo(
() =>
Object.values(config.functions).sort((a, b) => {
const kindCompare = a.kind.localeCompare(b.kind);
return kindCompare || a.label.localeCompare(b.label);
}),
[config.functions],
);
const updateRoot = (updates: Partial<VideoCanvasFunctionConfig>) => {
setConfig((current) => ({ ...current, ...updates }));
};
const updateFunction = (
key: string,
updates: Partial<VideoCanvasFunctionDefinition>,
) => {
setConfig((current) => ({
...current,
functions: {
...current.functions,
[key]: {
...current.functions[key],
...updates,
},
},
}));
};
const save = async () => {
setSaving(true);
try {
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
value: config,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.error || "保存失败");
}
toast.success("视频画布功能配置已保存");
await mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
};
const resetToDefault = () => {
setConfig(defaultVideoCanvasFunctionConfig);
toast.info("已恢复默认配置,保存后生效");
};
return (
<main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-6 py-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">视频画布功能设置</h1>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词、默认模型和扣点。前端优先按复刻目标触发,后端缺口可以先留空或后续替换。
</p>
</div>
<div className="flex gap-2">
<Button disabled={saving || isLoading} onClick={resetToDefault} type="button" variant="outline">
恢复默认
</Button>
<Button disabled={saving || isLoading} onClick={save} type="button">
{saving ? "保存中..." : "保存配置"}
</Button>
</div>
</div>
<Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-xl">全局模型与开关</CardTitle>
<CardDescription>
默认模型可填写现有后端的模型标识;保持 auto 时由各接口按当前可用模型选择。
</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="video-canvas-enabled">启用视频画布功能串联</Label>
<Switch
checked={config.enabled}
id="video-canvas-enabled"
onCheckedChange={(checked) => updateRoot({ enabled: checked })}
/>
</div>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="defaultImageModel">默认图片/编辑模型</Label>
<Input
id="defaultImageModel"
onChange={(event) => updateRoot({ defaultImageModel: event.target.value })}
value={config.defaultImageModel}
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultVideoModel">默认视频模型</Label>
<Input
id="defaultVideoModel"
onChange={(event) => updateRoot({ defaultVideoModel: event.target.value })}
value={config.defaultVideoModel}
/>
</div>
<div className="space-y-2">
<Label htmlFor="defaultMultimodalModel">默认多模态模型</Label>
<Input
id="defaultMultimodalModel"
onChange={(event) => updateRoot({ defaultMultimodalModel: event.target.value })}
value={config.defaultMultimodalModel}
/>
</div>
</CardContent>
</Card>
<div className="grid gap-4">
{functions.map((item) => (
<Card className={!item.enabled ? "opacity-70" : undefined} key={item.key}>
<CardHeader className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
<span>{item.label}</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
{functionKindLabels[item.kind]}
</span>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-normal text-muted-foreground">
{item.route}
</span>
</CardTitle>
<CardDescription className="mt-2">{item.description}</CardDescription>
</div>
<div className="flex items-center gap-3">
<Label htmlFor={`enabled-${item.key}`}>启用</Label>
<Switch
checked={item.enabled}
id={`enabled-${item.key}`}
onCheckedChange={(checked) => updateFunction(item.key, { enabled: checked })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
<div className="space-y-2">
<Label htmlFor={`model-${item.key}`}>模型覆盖</Label>
<Input
id={`model-${item.key}`}
onChange={(event) => updateFunction(item.key, { model: event.target.value })}
placeholder="留空使用全局默认模型"
value={item.model || ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`credit-${item.key}`}>扣点</Label>
<Input
id={`credit-${item.key}`}
min={0}
onChange={(event) =>
updateFunction(item.key, { creditCost: Number(event.target.value || 0) })
}
type="number"
value={item.creditCost ?? 0}
/>
</div>
</div>
</CardHeader>
<CardContent>
<Label htmlFor={`prompt-${item.key}`}>预设提示词 / 规则</Label>
<Textarea
className="mt-2 min-h-28"
id={`prompt-${item.key}`}
onChange={(event) => updateFunction(item.key, { prompt: event.target.value })}
value={item.prompt}
/>
</CardContent>
</Card>
))}
</div>
</main>
);
}

View File

@ -38,6 +38,10 @@ import {
normalizeThreeDModelAliasConfig, normalizeThreeDModelAliasConfig,
THREED_MODEL_ALIAS_CONFIG_SETTING_KEY, THREED_MODEL_ALIAS_CONFIG_SETTING_KEY,
} from "@/lib/model3d/model-alias-config"; } from "@/lib/model3d/model-alias-config";
import {
normalizeVideoCanvasFunctionConfig,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
} from "@/lib/video-canvas/config";
const settingSchema = z.object({ const settingSchema = z.object({
key: z.string(), key: z.string(),
@ -196,6 +200,10 @@ export async function POST(request: Request) {
nextValue = normalizedValue; nextValue = normalizedValue;
} }
if (key === VIDEO_CANVAS_FUNCTION_CONFIG_KEY) {
nextValue = normalizeVideoCanvasFunctionConfig(value);
}
if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) { if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) {
const existingSetting = await getSystemSetting({ const existingSetting = await getSystemSetting({
key: THIRD_PARTY_SQLSERVER_CONFIG_KEY, key: THIRD_PARTY_SQLSERVER_CONFIG_KEY,

View File

@ -0,0 +1,17 @@
import { getSystemSetting } from "@/lib/db/queries";
import {
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
export const dynamic = "force-dynamic";
export async function GET() {
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(() => null);
const config = setting?.value
? normalizeVideoCanvasFunctionConfig(setting.value)
: defaultVideoCanvasFunctionConfig;
return Response.json({ config });
}

View File

@ -44,7 +44,18 @@ import { toast } from "sonner";
import useSWR from "swr"; import useSWR from "swr";
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json"; import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import { canvasService } from "@/services/canvas"; import {
canvasService,
imageGenerationService,
videoGenerationService,
workflowAnalysisService,
} from "@/services/canvas";
import {
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
} from "@/lib/video-canvas/config";
type ActivePanel = type ActivePanel =
| "add" | "add"
@ -371,7 +382,8 @@ const OFFICIAL_NODE_GAP = 100;
const OFFICIAL_TEXT_RESOURCE_HEIGHT = 232; const OFFICIAL_TEXT_RESOURCE_HEIGHT = 232;
const OFFICIAL_TEXT_RESOURCE_WIDTH = 350; const OFFICIAL_TEXT_RESOURCE_WIDTH = 350;
const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7; const OFFICIAL_IMAGE_TOOLBAR_SCALE = 0.7;
const OFFICIAL_COMPOSER_SCALE = 1; const OFFICIAL_COMPOSER_SCALE = 0.42;
const OFFICIAL_ZOOM_DOCK_SCALE = 0.5;
const PROMPT_COMPOSER_BASE_WIDTH = 895; const PROMPT_COMPOSER_BASE_WIDTH = 895;
const PROMPT_COMPOSER_BASE_HEIGHT = 286; const PROMPT_COMPOSER_BASE_HEIGHT = 286;
const PRESET_COMPOSER_BASE_HEIGHT = 238; const PRESET_COMPOSER_BASE_HEIGHT = 238;
@ -1138,6 +1150,45 @@ function getNodeSourceMediaUrl(node: CloneNode | null | undefined) {
return getDraftString(node, "sourceUrl") || getNodeMediaUrl(node); 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[] { function createEqualGridRects(rows: number, columns: number): GridCropRect[] {
return Array.from({ length: rows * columns }).map((_, index) => { return Array.from({ length: rows * columns }).map((_, index) => {
const column = index % columns; const column = index % columns;
@ -1669,6 +1720,14 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const canvasRef = useRef<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const { data: siteBrandSettings } = useSWR<SiteBrandSettings>("/api/site-settings", fetcher); 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 [nodes, setNodes] = useState<CloneNode[]>([]);
const [edges, setEdges] = useState<CloneEdge[]>([]); const [edges, setEdges] = useState<CloneEdge[]>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
@ -2435,7 +2494,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const title = labels[action]; const title = labels[action];
if (action === "grid") { if (action === "grid") {
const { columns, rows } = normalizeGridOption(option || "9宫格"); const { columns, rows } = normalizeGridOption(option || "9宫格");
const gridNode = createNineGridCompositeNode({ const gridDefinition = getConfiguredFunction(videoCanvasFunctionConfig, "multi-view-nine-grid");
let gridNode = createNineGridCompositeNode({
anchor, anchor,
columns, columns,
option, option,
@ -2444,6 +2504,33 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
x: rightX, x: rightX,
y: centerY(347), 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]); setNodes((current) => [...current, gridNode]);
setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: gridNode.id }]); setEdges((current) => [...current, { id: makeId("edge"), source: anchor.id, target: gridNode.id }]);
setSelectedNodeId(gridNode.id); setSelectedNodeId(gridNode.id);
@ -2455,8 +2542,92 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
return; 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 created = createTargetModuleNode("image", rightX, 0, nodes.length + 1);
const prompt = option?.trim() || title;
const resultNode: CloneNode = { const resultNode: CloneNode = {
...created, ...created,
title, title,
@ -2485,7 +2656,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
setActivePanel(null); setActivePanel(null);
toast.success(`已创建「${title}」工作流节点`); toast.success(`已创建「${title}」工作流节点`);
}, },
[nodes] [nodes, videoCanvasFunctionConfig]
); );
const updateTextResourceContent = useCallback((nodeId: string, content: string) => { const updateTextResourceContent = useCallback((nodeId: string, content: string) => {
@ -2505,19 +2676,63 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
}, []); }, []);
const submitSelectedImagePrompt = useCallback( const submitSelectedImagePrompt = useCallback(
(nodeId: string, payload: ComposerSubmitPayload) => { async (nodeId: string, payload: ComposerSubmitPayload) => {
const anchor = nodes.find((node) => node.id === nodeId); const anchor = nodes.find((node) => node.id === nodeId);
if (!anchor) return; if (!anchor) return;
const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL; const sourceUrl = getNodeMediaUrl(anchor) || CHARACTER_IMAGE_URL;
const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP); const rightX = Math.round(anchor.x + anchor.width + OFFICIAL_NODE_GAP);
const created = createTargetModuleNode("image", rightX, 0, nodes.length + 1); const starterMode = getPresetMode(anchor);
const prompt = payload.prompt.trim() || payload.slashScene || "生成角色三视图"; 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 = { const resultNode: CloneNode = {
...created, ...created,
title: payload.slashScene || "图片生成", title: payload.slashScene || definition.label || "图片生成",
y: Math.round(anchor.y + anchor.height / 2 - created.height / 2), y:
generatedNode?.y ??
Math.round(anchor.y + anchor.height / 2 - created.height / 2),
draftValues: { draftValues: {
...(created.draftValues || {}), ...(created.draftValues || {}),
generated: Boolean(generatedUrl) || created.draftValues?.generated,
params: { params: {
prompt, prompt,
references: payload.references, references: payload.references,
@ -2528,6 +2743,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
}, },
sourceNodeId: anchor.id, sourceNodeId: anchor.id,
sourceUrl, sourceUrl,
targetPreset: starterMode || undefined,
toolbarOption: payload.slashScene || "",
}, },
}; };
const referenceEdges = payload.references const referenceEdges = payload.references
@ -2545,9 +2762,113 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
setNodeContextMenu(null); setNodeContextMenu(null);
setConnectionMenu(null); setConnectionMenu(null);
setActivePanel(null); setActivePanel(null);
toast.success("已按当前提示词创建图片生成节点"); toast.success(generatedUrl ? "已按当前提示词生成图片结果" : "已按当前提示词创建图片生成节点");
}, },
[nodes] [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( const createFreeNode = useCallback(
@ -2598,14 +2919,170 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
toast.success("已复制节点"); toast.success("已复制节点");
}, [nodes]); }, [nodes]);
const runNode = useCallback((nodeId: string) => { const runNode = useCallback(async (nodeId: string) => {
const node = nodes.find((item) => item.id === nodeId); const node = nodes.find((item) => item.id === nodeId);
if (!node) return; if (!node) return;
setSelectedNodeId(nodeId); setSelectedNodeId(nodeId);
setNodeContextMenu(null); setNodeContextMenu(null);
setConnectionMenu(null); setConnectionMenu(null);
toast.message(`${node.title} 生成接口待接入。`);
}, [nodes]); 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) => { const deleteNode = useCallback((nodeId: string) => {
setNodes((current) => current.filter((node) => node.id !== nodeId)); setNodes((current) => current.filter((node) => node.id !== nodeId));
@ -2971,6 +3448,13 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
nodes={nodes} nodes={nodes}
onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)} onComposerSubmit={(payload) => submitSelectedImagePrompt(selectedPresetControlNode.id, payload)}
onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)} onImageToolAction={(action, option) => runSelectedImageToolAction(selectedPresetControlNode.id, action, option)}
onPresetSubmit={(prompt) =>
runPresetComposerSubmit(
selectedPresetControlNode.id,
selectedPresetMode ?? "character-three-view",
prompt
)
}
selectedNode={selectedPresetControlNode} selectedNode={selectedPresetControlNode}
/> />
) : null} ) : null}
@ -3139,7 +3623,22 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
{angleEditorNode ? ( {angleEditorNode ? (
<MultiAngleEditorDialog <MultiAngleEditorDialog
node={angleEditorNode} node={angleEditorNode}
onApply={(settings) => { 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) => setNodes((current) =>
current.map((node) => current.map((node) =>
node.id === angleEditorNode.id node.id === angleEditorNode.id
@ -3160,8 +3659,39 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
: node : 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); setAngleEditorNodeId(null);
toast.success("已应用多角度参数,生成接口待接入。"); toast.success(generatedUrl ? "已按多角度参数生成结果" : "已应用多角度参数");
}} }}
onClose={() => setAngleEditorNodeId(null)} onClose={() => setAngleEditorNodeId(null)}
/> />
@ -3356,41 +3886,43 @@ function ZoomDock({
return ( return (
<div className="absolute bottom-5 left-5 z-40" data-canvas-overlay="true"> <div className="absolute bottom-5 left-5 z-40" data-canvas-overlay="true">
{overviewOpen ? ( <div style={{ transform: `scale(${OFFICIAL_ZOOM_DOCK_SCALE})`, transformOrigin: "bottom left" }}>
<CanvasOverviewPopover nodes={nodes} selectedNodeId={selectedNodeId || null} /> {overviewOpen ? (
) : null} <CanvasOverviewPopover nodes={nodes} selectedNodeId={selectedNodeId || null} />
<div className="flex h-[72px] items-center gap-2 rounded-[18px] border border-white/9 bg-[#202020]/95 px-3 text-white/74 shadow-[0_18px_48px_rgba(0,0,0,0.42)] backdrop-blur-[10px]"> ) : null}
<ZoomDockButton active={overviewOpen} label="画布总览" onClick={onToggleOverview}> <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]">
<GridTilesGlyph /> <ZoomDockButton active={overviewOpen} label="画布总览" onClick={onToggleOverview}>
</ZoomDockButton> <GridTilesGlyph />
<ZoomDockButton active={Boolean(selectedNodeId)} label="定位选中" onClick={onCenter} selectedOutline> </ZoomDockButton>
<MapPinGlyph /> <ZoomDockButton active={Boolean(selectedNodeId)} label="定位选中" onClick={onCenter} selectedOutline>
</ZoomDockButton> <MapPinGlyph />
<ZoomDockButton active={snapEnabled} label={snapEnabled ? "吸附已开启" : "吸附已关闭"} onClick={onToggleSnap}> </ZoomDockButton>
<MagnetGlyph /> <ZoomDockButton active={snapEnabled} label={snapEnabled ? "吸附已开启" : "吸附已关闭"} onClick={onToggleSnap}>
</ZoomDockButton> <MagnetGlyph />
<div className="mx-2 h-8 w-px bg-white/10" /> </ZoomDockButton>
<button <div className="mx-1 h-7 w-px bg-white/10" />
className="flex h-12 w-12 cursor-pointer items-center justify-center rounded-[12px] text-[24px] leading-none text-white/72 transition-colors hover:bg-white/8 hover:text-white" <button
onClick={onZoomOut} 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"
type="button" onClick={onZoomOut}
> type="button"
- >
</button> -
<button </button>
className="h-12 min-w-[86px] cursor-pointer rounded-[12px] px-2 text-center text-[20px] font-medium tabular-nums leading-[48px] text-white/86 transition-colors hover:bg-white/8" <button
onClick={onCenter} 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"
type="button" onClick={onCenter}
> type="button"
{Math.round(zoom * 100)}% >
</button> {Math.round(zoom * 100)}%
<button </button>
className="flex h-12 w-12 cursor-pointer items-center justify-center rounded-[12px] text-[28px] leading-none text-white/82 transition-colors hover:bg-white/8 hover:text-white" <button
onClick={onZoomIn} 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"
type="button" onClick={onZoomIn}
> type="button"
+ >
</button> +
</button>
</div>
</div> </div>
</div> </div>
); );
@ -3412,7 +3944,7 @@ function ZoomDockButton({
return ( return (
<button <button
className={cn( className={cn(
"flex h-12 w-12 cursor-pointer items-center justify-center rounded-[12px] text-white/74 transition-colors hover:bg-white/10 hover:text-white", "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 && "bg-white/10 text-white",
active && selectedOutline && "bg-transparent shadow-[0_0_0_2px_#8dc5ff]" active && selectedOutline && "bg-transparent shadow-[0_0_0_2px_#8dc5ff]"
)} )}
@ -3629,6 +4161,7 @@ function PresetWorkflowControls({
nodes, nodes,
onComposerSubmit, onComposerSubmit,
onImageToolAction, onImageToolAction,
onPresetSubmit,
selectedNode, selectedNode,
}: { }: {
anchor: PresetControlAnchor; anchor: PresetControlAnchor;
@ -3636,6 +4169,7 @@ function PresetWorkflowControls({
nodes: CloneNode[]; nodes: CloneNode[];
onComposerSubmit: (payload: ComposerSubmitPayload) => void; onComposerSubmit: (payload: ComposerSubmitPayload) => void;
onImageToolAction: (action: ImageToolbarAction, option?: string) => void; onImageToolAction: (action: ImageToolbarAction, option?: string) => void;
onPresetSubmit: (prompt: string) => void;
selectedNode: CloneNode; selectedNode: CloneNode;
}) { }) {
if (mode === "character-three-view") { if (mode === "character-three-view") {
@ -3667,6 +4201,7 @@ function PresetWorkflowControls({
<PresetComposerPanel <PresetComposerPanel
anchor={anchor} anchor={anchor}
credit="8" credit="8"
onSubmit={onPresetSubmit}
prompt={STORY_SCRIPT_PROMPT} prompt={STORY_SCRIPT_PROMPT}
submitMessage="故事脚本生成接口待接入。" submitMessage="故事脚本生成接口待接入。"
title="脚本生成器" title="脚本生成器"
@ -3690,6 +4225,7 @@ function PresetWorkflowControls({
<PresetComposerPanel <PresetComposerPanel
anchor={anchor} anchor={anchor}
credit="28" credit="28"
onSubmit={onPresetSubmit}
prompt="以当前图为首帧生成视频。" prompt="以当前图为首帧生成视频。"
submitMessage="首帧图生视频接口待接入。" submitMessage="首帧图生视频接口待接入。"
thumbnailUrl={FIRST_FRAME_IMAGE_URL} thumbnailUrl={FIRST_FRAME_IMAGE_URL}
@ -3701,7 +4237,7 @@ function PresetWorkflowControls({
return ( return (
<div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true"> <div className="pointer-events-none absolute inset-0 z-30" data-canvas-overlay="true">
<AudioToVideoComposerPanel anchor={anchor} /> <AudioToVideoComposerPanel anchor={anchor} onSubmit={onPresetSubmit} />
</div> </div>
); );
} }
@ -3779,7 +4315,15 @@ function CharacterPresetToolbar({
); );
} }
function AudioToVideoComposerPanel({ anchor }: { anchor?: PresetControlAnchor }) { function AudioToVideoComposerPanel({
anchor,
onSubmit,
}: {
anchor?: PresetControlAnchor;
onSubmit?: (prompt: string) => void;
}) {
const [prompt, setPrompt] = useState(AUDIO_TO_VIDEO_PROMPT);
return ( return (
<div <div
className="pointer-events-auto absolute overflow-visible" className="pointer-events-auto absolute overflow-visible"
@ -3845,8 +4389,9 @@ function AudioToVideoComposerPanel({ anchor }: { anchor?: PresetControlAnchor })
</div> </div>
<textarea <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" className="mt-5 h-[62px] w-full resize-none bg-transparent text-[24px] leading-8 text-white/78 outline-none placeholder:text-white/46"
defaultValue={AUDIO_TO_VIDEO_PROMPT} onChange={(event) => setPrompt(event.target.value)}
placeholder="根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。" placeholder="根据上传的音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。"
value={prompt}
/> />
<div className="absolute bottom-5 left-5 right-[86px] flex items-center gap-7 overflow-hidden text-[22px] text-white/82"> <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("视频模型选择后端待接入。")} /> <ComposerControl label="Seedance 2.0" icon="▥" dropdown onClick={() => toast.info("视频模型选择后端待接入。")} />
@ -3862,7 +4407,13 @@ function AudioToVideoComposerPanel({ anchor }: { anchor?: PresetControlAnchor })
</div> </div>
<button <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" 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={() => toast.info("音频生视频生成接口待接入。")} onClick={() => {
if (onSubmit) {
onSubmit(prompt);
return;
}
toast.info("音频生视频生成接口待接入。");
}}
type="button" type="button"
> >
↑ ↑
@ -4341,6 +4892,7 @@ function AudioResourcePanel() {
function PresetComposerPanel({ function PresetComposerPanel({
anchor, anchor,
credit, credit,
onSubmit,
prompt, prompt,
submitMessage, submitMessage,
thumbnailUrl, thumbnailUrl,
@ -4348,6 +4900,7 @@ function PresetComposerPanel({
}: { }: {
anchor?: PresetControlAnchor; anchor?: PresetControlAnchor;
credit: string; credit: string;
onSubmit?: (prompt: string) => void;
prompt: string; prompt: string;
submitMessage: string; submitMessage: string;
thumbnailUrl?: string; thumbnailUrl?: string;
@ -4409,7 +4962,13 @@ function PresetComposerPanel({
</div> </div>
<button <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" 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={() => toast.info(submitMessage)} onClick={() => {
if (onSubmit) {
onSubmit(draftPrompt);
return;
}
toast.info(submitMessage);
}}
type="button" type="button"
> >
↑ ↑

View File

@ -0,0 +1,68 @@
# 视频画布复刻功能映射
本文档记录 LibTV 目标画布的功能分类、官方接口线索,以及当前项目的前后端串联策略。目标优先级是“复刻前端和工作流逻辑优先,后端缺口留空可配置”。
## 官方接口线索
- 工具定义:`GET /api/tool_spec/list`
- 工作流预设:`GET /api/canvas/workflow/list`
- 创建生成任务:`POST /api/task/generation/create`
- 生成进度:`POST /api/task/generation/progress/batch`
- 打光专项:`POST /api/task/generation/lighting`
- 视频优化:`POST /api/task/generation/video/opt`
- 扣点估算:`POST /api/task/generation/power/calculator` 和 `/batch`
- 画布保存:`POST /api/canvas/project/draft/update`
抓取到的官方工具规格里,工具大类约为:图片 22、视频 38、音频 3、文本 3、合成 1。模式大类包括 image2image、image2video、singleImage2video、frames2video、audio2video、image2text、video2text、videoEdit2video、video2video。
## 功能分类
| 功能 | 官方逻辑判断 | 当前串联 |
| --- | --- | --- |
| 角色三视图 | 图片编辑/图生图模型,参考图保持角色一致性 | `/api/canvas/generate/image`,提示词在系统设置配置 |
| 多机位九宫格 | 图片编辑/图生图模型生成带黑色分割线的 3x3 分镜图 | `/api/canvas/generate/image`,生成结果再支持宫格切分 |
| 宫格切分 | 图像识别/本地分割线检测,按黑线切 4/9/16/25 张 | 当前前端本地检测黑线并生成切片节点 |
| 多角度编辑器 | 图片编辑模型,参数含水平环绕、垂直俯仰、景别缩放 | `/api/angle-edit` |
| 高清 | 图像增强/超分接口 | `/api/enhance` |
| 扩图/720 全景 | 图片拓展或图片编辑模型 | `/api/extend` |
| 重绘/擦除 | 图片编辑生图模型,擦除后续可替换蒙版接口 | `/api/canvas/generate/image` |
| 抠图 | 图像分割/抠图接口 | `/api/rmbg` |
| 图片反推提示词 | 多模态模型 image2text | `/api/canvas/workflow/analyze` |
| 故事/分镜脚本 | 文本模型或多模态模型,输出表格化镜号 | `/api/canvas/workflow/analyze` |
| 文生视频 | 视频生成模型 text2video | `/api/canvas/generate/video` |
| 首帧图生视频 | 视频生成模型 image2video | `/api/canvas/generate/video` |
| 首尾帧生视频 | 视频生成模型 frames2video | `/api/canvas/generate/video`,最多传 4 张图 |
| 音频生视频 | audio2video,官方会结合音频节拍/情绪和参考图 | 当前先用提示词+参考图走 `/api/canvas/generate/video`,音频节拍解析后端后续补 |
| 工具箱工作流模板 | 预设画布工作流,点击后在当前画布展开节点 | 前端已按官方工作流思路展开,后续模板内容可配置化扩充 |
## 配置入口
新增系统设置键:`video_canvas_function_config`
新增页面:`/settings/video-canvas`
可配置内容:
- 总开关
- 默认图片/视频/多模态模型
- 每个功能的启用状态
- 每个功能的模型覆盖
- 每个功能的扣点
- 每个功能的预设提示词/规则
## 当前已接入的前端触发点
- 选中图片后的九宫格、高清、扩图、打光、全景、重绘、擦除、抠图
- 九宫格切分,识别黑色分割线拆为单独镜头图
- 多角度编辑器提交
- 角色三视图提示词框提交
- 故事脚本、首帧视频、音频生视频预设 Composer 提交
- 右键“立即生成”按节点类型执行图片/视频/多模态分析
## 后续后端缺口
- 真正的 audio2video 音频节拍/情绪解析输入
- 视频编辑到视频、视频参考生成分镜脚本的专用接口
- 擦除/局部重绘的蒙版编辑接口
- 官方扣点估算等价接口
- 官方 67 个工具规格的完整后台模型路由表

313
lib/video-canvas/config.ts Normal file
View File

@ -0,0 +1,313 @@
export const VIDEO_CANVAS_FUNCTION_CONFIG_KEY = "video_canvas_function_config";
export type VideoCanvasFunctionKind =
| "multimodal"
| "image"
| "image-edit"
| "video"
| "audio"
| "utility"
| "workflow";
export type VideoCanvasBackendRoute =
| "canvas-generate-image"
| "canvas-generate-video"
| "canvas-workflow-analyze"
| "angle-edit"
| "enhance"
| "enhance-pro"
| "extend"
| "remove-bg"
| "layer-split"
| "local";
export type VideoCanvasFunctionDefinition = {
key: string;
label: string;
kind: VideoCanvasFunctionKind;
route: VideoCanvasBackendRoute;
enabled: boolean;
model?: string;
creditCost?: number;
prompt: string;
description: string;
requiresBackend?: boolean;
};
export type VideoCanvasFunctionConfig = {
enabled: boolean;
defaultImageModel: string;
defaultVideoModel: string;
defaultMultimodalModel: string;
functions: Record<string, VideoCanvasFunctionDefinition>;
};
const defaultFunctions: VideoCanvasFunctionDefinition[] = [
{
key: "character-three-view",
label: "角色三视图",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
description: "官方角色三视图预设,使用参考图生图/编辑模型。",
},
{
key: "multi-view-nine-grid",
label: "多机位九宫格",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"以选中图片为角色/主体参考,生成多视角 3x3 九宫格分镜图。保持主体一致,九个镜头包含远景、全身、侧身、背面、半身、近景、局部细节等视角,使用清晰黑色分割线分隔每一格,白色或干净背景。",
description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。",
},
{
key: "grid-split",
label: "宫格切分",
kind: "utility",
route: "local",
enabled: true,
creditCost: 0,
prompt: "识别黑色宫格分割线,将合成图切分为可单独调整的镜头图片。",
description: "浏览器端识别黑色分割线并生成切片节点;后端可后续替换为更稳的图像分割服务。",
},
{
key: "lighting",
label: "打光",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt:
"在保持主体、构图和身份一致的前提下,对图片进行电影级光影重塑,增强轮廓光、层次、对比和空间感,保留自然材质细节。",
description: "图片编辑生图模型,复刻官方打光工具。",
},
{
key: "hd-upscale",
label: "高清",
kind: "utility",
route: "enhance",
enabled: true,
creditCost: 8,
prompt: "提升图片清晰度、细节和分辨率,保持原图内容不变。",
description: "接入现有图片增强接口;高清重绘可在模型字段指定增强 Pro。",
},
{
key: "panorama-720",
label: "720 全景",
kind: "image-edit",
route: "extend",
enabled: true,
creditCost: 14,
prompt:
"基于当前图片向四周扩展为 720 全景视野,保持主体与场景一致,补全自然环境和空间透视,避免变形和重复纹理。",
description: "优先使用拓图接口,必要时可切到图片编辑生图模型。",
},
{
key: "image-outpaint",
label: "扩图",
kind: "utility",
route: "extend",
enabled: true,
creditCost: 10,
prompt: "在保持主体不变的前提下扩展画面边界,补全自然背景和构图。",
description: "对应官方高清菜单里的扩图。",
},
{
key: "image-repaint",
label: "重绘",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 14,
prompt: "保持主体结构和身份一致,按照用户补充提示重绘画面局部或整体风格。",
description: "对应官方高清菜单里的重绘。",
},
{
key: "image-erase",
label: "擦除",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
creditCost: 10,
prompt: "根据用户标记或文字说明移除指定元素,并自然补全背景纹理。",
description: "当前以前端提示词占位接图片编辑模型,后续可替换为蒙版编辑接口。",
},
{
key: "remove-bg",
label: "抠图",
kind: "utility",
route: "remove-bg",
enabled: true,
creditCost: 4,
prompt: "移除图片背景,保留主体透明或干净背景结果。",
description: "接入现有抠图接口。",
},
{
key: "image-crop",
label: "裁剪",
kind: "utility",
route: "local",
enabled: true,
creditCost: 0,
prompt: "按用户选择的裁剪比例或宫格切片范围处理图片。",
description: "本地画布裁剪/宫格切分能力。",
},
{
key: "angle-edit",
label: "多角度编辑器",
kind: "image-edit",
route: "angle-edit",
enabled: true,
creditCost: 1,
prompt: "根据水平环绕、垂直俯仰和景别缩放参数生成新的角色/主体视角。",
description: "接现有 /api/angle-edit,多角度编辑器提交时触发。",
},
{
key: "story-script",
label: "故事脚本生成",
kind: "multimodal",
route: "canvas-workflow-analyze",
enabled: true,
creditCost: 6,
prompt: "根据输入文本、图片或视频参考,生成结构化分镜脚本,包含镜号、场景、画面、声音和镜头节奏。",
description: "多模态大模型/文本模型生成脚本表格。",
},
{
key: "image-to-prompt",
label: "图片反推提示词",
kind: "multimodal",
route: "canvas-workflow-analyze",
enabled: true,
creditCost: 3,
prompt: "分析参考图片,输出结构化中文提示词,包括主体、环境、构图、光影、镜头语言和风格关键词。",
description: "多模态识图生成提示词。",
},
{
key: "text-to-video",
label: "文生视频",
kind: "video",
route: "canvas-generate-video",
enabled: true,
creditCost: 14,
prompt: "根据文本描述生成短视频,保持镜头运动、节奏和电影级质感。",
description: "视频生成模型,走现有视频生成接口。",
},
{
key: "first-frame-video",
label: "首帧图生视频",
kind: "video",
route: "canvas-generate-video",
enabled: true,
creditCost: 14,
prompt: "以参考图片作为视频首帧,生成自然流畅的短视频。",
description: "图片到视频/首帧生视频。",
},
{
key: "first-last-frame-video",
label: "首尾帧生成视频",
kind: "video",
route: "canvas-generate-video",
enabled: true,
creditCost: 14,
prompt: "以两张参考图作为首帧和尾帧,生成首尾连贯的短视频。",
description: "首尾帧视频生成。",
},
{
key: "audio-to-video",
label: "音频生视频",
kind: "video",
route: "canvas-generate-video",
enabled: true,
creditCost: 14,
prompt: "根据上传音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。",
description: "官方为音频+参考图到视频;当前先接视频生成提示词和参考图,音频节拍解析接口后续补充。",
requiresBackend: true,
},
];
export const defaultVideoCanvasFunctionConfig: VideoCanvasFunctionConfig = {
enabled: true,
defaultImageModel: "auto",
defaultVideoModel: "auto",
defaultMultimodalModel: "auto",
functions: Object.fromEntries(defaultFunctions.map((item) => [item.key, item])),
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeFunctionDefinition(
key: string,
value: unknown,
fallback: VideoCanvasFunctionDefinition,
): VideoCanvasFunctionDefinition {
const incoming = isRecord(value) ? value : {};
const normalized: VideoCanvasFunctionDefinition = {
...fallback,
key,
label: String(incoming.label || fallback.label),
kind: (incoming.kind as VideoCanvasFunctionKind) || fallback.kind,
route: (incoming.route as VideoCanvasBackendRoute) || fallback.route,
enabled: typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled,
model: typeof incoming.model === "string" ? incoming.model : fallback.model,
creditCost:
typeof incoming.creditCost === "number" && Number.isFinite(incoming.creditCost)
? Math.max(0, incoming.creditCost)
: fallback.creditCost,
prompt: String(incoming.prompt || fallback.prompt),
description: String(incoming.description || fallback.description),
requiresBackend:
typeof incoming.requiresBackend === "boolean"
? incoming.requiresBackend
: fallback.requiresBackend,
};
return normalized;
}
export function normalizeVideoCanvasFunctionConfig(value: unknown): VideoCanvasFunctionConfig {
if (!isRecord(value)) return defaultVideoCanvasFunctionConfig;
const incomingFunctions = isRecord(value.functions) ? value.functions : {};
const functions: Record<string, VideoCanvasFunctionDefinition> = {};
for (const [key, fallback] of Object.entries(defaultVideoCanvasFunctionConfig.functions)) {
functions[key] = normalizeFunctionDefinition(key, incomingFunctions[key], fallback);
}
for (const [key, customValue] of Object.entries(incomingFunctions)) {
if (functions[key] || !isRecord(customValue)) continue;
const fallback: VideoCanvasFunctionDefinition = {
key,
label: String(customValue.label || key),
kind: (customValue.kind as VideoCanvasFunctionKind) || "workflow",
route: (customValue.route as VideoCanvasBackendRoute) || "local",
enabled: customValue.enabled !== false,
creditCost: typeof customValue.creditCost === "number" ? Math.max(0, customValue.creditCost) : 0,
prompt: String(customValue.prompt || ""),
description: String(customValue.description || "自定义视频画布功能"),
};
functions[key] = normalizeFunctionDefinition(key, customValue, fallback);
}
return {
enabled: value.enabled !== false,
defaultImageModel:
typeof value.defaultImageModel === "string" && value.defaultImageModel.trim()
? value.defaultImageModel.trim()
: defaultVideoCanvasFunctionConfig.defaultImageModel,
defaultVideoModel:
typeof value.defaultVideoModel === "string" && value.defaultVideoModel.trim()
? value.defaultVideoModel.trim()
: defaultVideoCanvasFunctionConfig.defaultVideoModel,
defaultMultimodalModel:
typeof value.defaultMultimodalModel === "string" && value.defaultMultimodalModel.trim()
? value.defaultMultimodalModel.trim()
: defaultVideoCanvasFunctionConfig.defaultMultimodalModel,
functions,
};
}