feat: update video canvas frontend workflow

This commit is contained in:
134 2026-07-10 17:59:12 +08:00
parent a5995ca0c4
commit 0d67e2935c
5 changed files with 15585 additions and 892 deletions

View File

@ -1,5 +1,7 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
import { toast } from "sonner";
@ -12,6 +14,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@ -30,6 +33,9 @@ import {
getVideoCanvasFunctionBillingCopy,
type VideoCanvasFunctionConfig,
type VideoCanvasFunctionDefinition,
type VideoCanvasEffectPreset,
type VideoCanvasMotionPreset,
type VideoCanvasRolePreset,
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
defaultVideoCanvasFunctionConfig,
normalizeVideoCanvasFunctionConfig,
@ -199,8 +205,9 @@ export function VideoCanvasSettingsClient() {
const functions = useMemo(
() =>
Object.values(config.functions).sort((a, b) => {
const kindCompare = a.kind.localeCompare(b.kind);
return kindCompare || a.label.localeCompare(b.label);
const compare = (left: string, right: string) =>
left === right ? 0 : left < right ? -1 : 1;
return compare(a.kind, b.kind) || compare(a.label, b.label);
}),
[config.functions],
);
@ -225,6 +232,135 @@ export function VideoCanvasSettingsClient() {
}));
};
const updateEffectPreset = (
index: number,
updates: Partial<VideoCanvasEffectPreset>,
) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const updateMotionPreset = (
index: number,
updates: Partial<VideoCanvasMotionPreset>,
) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addMotionPreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
motionPresets: [
...current.motionPresets,
{
id: `motion-${createdAt}`,
name: "新运镜预设",
previewUrl: "/video-canvas/effects/multi-angle.webp",
prompt: "镜头平稳移动,保持主体和空间关系清晰。",
},
],
}));
};
const removeMotionPreset = (index: number) => {
setConfig((current) => ({
...current,
motionPresets: current.motionPresets.filter(
(_, presetIndex) => presetIndex !== index,
),
}));
};
const addEffectPreset = () => {
setConfig((current) => ({
...current,
effectPresets: [
...current.effectPresets,
{
id: `effect-${Date.now()}`,
title: "新视频特效",
author: "官方预设",
usageCount: 0,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
prompt: "保持参考主体稳定,应用自然、连续的视频特效和镜头运动。",
commercial: true,
},
],
}));
};
const removeEffectPreset = (index: number) => {
setConfig((current) => ({
...current,
effectPresets: current.effectPresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const updateRolePreset = (
index: number,
updates: Partial<VideoCanvasRolePreset>,
) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.map((preset, presetIndex) =>
presetIndex === index ? { ...preset, ...updates } : preset,
),
}));
};
const addRolePreset = () => {
const createdAt = Date.now();
setConfig((current) => ({
...current,
rolePresets: [
...current.rolePresets,
{
id: `role-${createdAt}`,
assetId: `public-role:role-${createdAt}`,
name: "新角色预设",
accent: "#525252",
description: "管理员配置的公共角色素材。",
prompt: "保持角色身份、五官、发型、服装和体型在所有视频镜头中一致。",
tags: "角色",
compliant: true,
images: {
expression: "",
face: "",
full: "/video-canvas/effects/product-light-sweep.webp",
threeView: "",
},
filters: {
age: "青年",
build: "匀称",
culture: "东亚",
era: "现代",
gender: "中性",
hairColor: "黑发",
species: "人类",
},
},
],
}));
};
const removeRolePreset = (index: number) => {
setConfig((current) => ({
...current,
rolePresets: current.rolePresets.filter((_, presetIndex) => presetIndex !== index),
}));
};
const save = async () => {
setSaving(true);
try {
@ -255,11 +391,17 @@ export function VideoCanvasSettingsClient() {
};
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">
<main className="settings-workspace mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5 sm:px-5">
<div className="flex flex-col gap-3 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">
<Button asChild className="mb-2" size="sm" variant="outline">
<Link href="/settings">
<ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
返回设置
</Link>
</Button>
<h1 className="text-xl font-semibold">视频画布功能设置</h1>
<p className="mt-1 max-w-3xl text-xs leading-5 text-muted-foreground">
这里集中配置复刻画布里的工具能力、预设提示词和默认模型。积分与扣费统一跟随全站模型/功能配置,不再在视频画布里单独维护一套价格。
</p>
</div>
@ -321,6 +463,354 @@ export function VideoCanvasSettingsClient() {
</CardContent>
</Card>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频运镜素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“运镜广场”的名称、预览图和插入提示词都从这里读取。点击运镜卡会按竞品规则把提示词追加到用户输入框,允许组合和重复使用。
</p>
</div>
<Button onClick={addMotionPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加运镜
</Button>
</div>
<div className="grid gap-3">
{config.motionPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-14 shrink-0 rounded-md object-cover"
src={preset.previewUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.prompt || "未设置插入提示词"}
</div>
</div>
<Button
aria-label={`删除运镜 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeMotionPreset(index);
}}
size="icon"
title={`删除运镜 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`motion-name-${index}`}>运镜名称</Label>
<Input
id={`motion-name-${index}`}
onChange={(event) =>
updateMotionPreset(index, { name: event.target.value })
}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`motion-preview-${index}`}>预览图地址</Label>
<Input
id={`motion-preview-${index}`}
onChange={(event) =>
updateMotionPreset(index, { previewUrl: event.target.value })
}
value={preset.previewUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`motion-prompt-${index}`}>插入提示词</Label>
<Textarea
id={`motion-prompt-${index}`}
onChange={(event) =>
updateMotionPreset(index, { prompt: event.target.value })
}
value={preset.prompt}
/>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频特效素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“特效”面板、素材节点封面、模型约束和生成提示词都从这里读取。选择特效时会创建结构化素材节点,不会把描述硬写进用户提示词。
</p>
</div>
<Button onClick={addEffectPreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加特效
</Button>
</div>
<div className="grid gap-3">
{config.effectPresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-12 w-12 shrink-0 rounded-md object-cover"
src={preset.thumbnailUrl}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.title}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.author} · {preset.model} · {preset.usageCount.toLocaleString()} 次使用
</div>
</div>
<Button
aria-label={`删除特效 ${preset.title}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeEffectPreset(index);
}}
size="icon"
title={`删除特效 ${preset.title}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`effect-title-${index}`}>特效名称</Label>
<Input
id={`effect-title-${index}`}
onChange={(event) => updateEffectPreset(index, { title: event.target.value })}
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-author-${index}`}>作者</Label>
<Input
id={`effect-author-${index}`}
onChange={(event) => updateEffectPreset(index, { author: event.target.value })}
value={preset.author}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-model-${index}`}>适用模型</Label>
<Input
id={`effect-model-${index}`}
onChange={(event) => updateEffectPreset(index, { model: event.target.value })}
value={preset.model}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`effect-count-${index}`}>展示使用次数</Label>
<Input
id={`effect-count-${index}`}
min={0}
onChange={(event) =>
updateEffectPreset(index, {
usageCount: Math.max(0, Number(event.target.value) || 0),
})
}
type="number"
value={preset.usageCount}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-thumbnail-${index}`}>封面地址</Label>
<Input
id={`effect-thumbnail-${index}`}
onChange={(event) =>
updateEffectPreset(index, { thumbnailUrl: event.target.value })
}
value={preset.thumbnailUrl}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`effect-prompt-${index}`}>生成提示词 / 后端规则</Label>
<Textarea
id={`effect-prompt-${index}`}
onChange={(event) => updateEffectPreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.commercial}
id={`effect-commercial-${index}`}
onCheckedChange={(checked) => updateEffectPreset(index, { commercial: checked })}
/>
<Label htmlFor={`effect-commercial-${index}`}>标记为可商用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">视频角色素材预设</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
视频节点“角色库”的公共角色、Seedance 合规资产 ID、四类角色图和隐藏生成提示词都从这里读取。角色引用通过画布连线保存,不会改写用户输入框。
</p>
</div>
<Button onClick={addRolePreset} size="sm" type="button" variant="outline">
<Plus className="mr-1.5 h-4 w-4" />
添加角色
</Button>
</div>
<div className="grid gap-3">
{config.rolePresets.map((preset, index) => (
<details
className="group rounded-lg border border-border bg-card px-4 py-3"
key={`${preset.id}-${index}`}
>
<summary className="flex cursor-pointer list-none items-center gap-3">
<img
alt=""
className="h-14 w-11 shrink-0 rounded-md object-cover object-top"
src={preset.images.full}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{preset.name}</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{preset.assetId} · {preset.tags || "未设置标签"}
</div>
</div>
<Button
aria-label={`删除角色 ${preset.name}`}
className="shrink-0"
onClick={(event) => {
event.preventDefault();
removeRolePreset(index);
}}
size="icon"
title={`删除角色 ${preset.name}`}
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</summary>
<div className="mt-4 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`role-name-${index}`}>角色名称</Label>
<Input
id={`role-name-${index}`}
onChange={(event) => updateRolePreset(index, { name: event.target.value })}
value={preset.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`role-asset-${index}`}>合规资产 ID</Label>
<Input
id={`role-asset-${index}`}
onChange={(event) => updateRolePreset(index, { assetId: event.target.value })}
value={preset.assetId}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-tags-${index}`}>展示标签</Label>
<Input
id={`role-tags-${index}`}
onChange={(event) => updateRolePreset(index, { tags: event.target.value })}
value={preset.tags}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-description-${index}`}>角色说明</Label>
<Textarea
id={`role-description-${index}`}
onChange={(event) => updateRolePreset(index, { description: event.target.value })}
value={preset.description}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`role-prompt-${index}`}>生成提示词 / 身份一致性规则</Label>
<Textarea
id={`role-prompt-${index}`}
onChange={(event) => updateRolePreset(index, { prompt: event.target.value })}
value={preset.prompt}
/>
</div>
{([
["full", "角色立绘"],
["face", "脸部近景"],
["expression", "表情参考"],
["threeView", "三视图"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-${key}-${index}`}>{label}地址</Label>
<Input
id={`role-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
images: { ...preset.images, [key]: event.target.value },
})
}
value={preset.images[key]}
/>
</div>
))}
{([
["gender", "性别"],
["age", "年龄段"],
["species", "种族"],
["era", "时代"],
["culture", "文化区域"],
["build", "体型"],
["hairColor", "发色"],
] as const).map(([key, label]) => (
<div className="space-y-2" key={key}>
<Label htmlFor={`role-filter-${key}-${index}`}>{label}</Label>
<Input
id={`role-filter-${key}-${index}`}
onChange={(event) =>
updateRolePreset(index, {
filters: { ...preset.filters, [key]: event.target.value },
})
}
value={preset.filters[key]}
/>
</div>
))}
<div className="flex items-center gap-3 md:col-span-2">
<Switch
checked={preset.compliant}
id={`role-compliant-${index}`}
onCheckedChange={(checked) => updateRolePreset(index, { compliant: checked })}
/>
<Label htmlFor={`role-compliant-${index}`}>可在 Seedance2.0 合规素材库中使用</Label>
</div>
</div>
</details>
))}
</div>
</section>
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">功能预设提示词</h2>

View File

@ -25,7 +25,7 @@ const VIDEO_ANALYSIS_MAX_SIDE = 960;
const VIDEO_ANALYSIS_TIMEOUT_MS = 15000;
const requestSchema = z.object({
mode: z.enum(["video-analysis", "character-scene-extraction", "agent-plan"]),
mode: z.enum(["video-analysis", "character-scene-extraction", "story-script", "agent-plan"]),
prompt: z.string().trim().optional(),
imageUrls: z.array(z.string().trim()).optional(),
videoUrls: z.array(z.string().trim()).optional(),
@ -118,6 +118,22 @@ function normalizeVideoAnalysisRows(
const sceneDescription = normalizeText(
raw.sceneDescription || raw.scene || raw.title || raw.description,
);
const narrative = normalizeText(
raw.narrative || raw.story || raw.storyContent || raw.narration || raw.summary,
);
const shot = normalizeText(raw.shot || raw.shotNumber || raw.index || index + 1);
const startTime = normalizeText(raw.startTime || raw.start || raw.start_time);
const endTime = normalizeText(raw.endTime || raw.end || raw.end_time);
const duration = normalizeText(raw.duration || raw.durationSeconds || raw.duration_seconds);
const shotSize = normalizeText(raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale);
const cameraAngle = normalizeText(raw.cameraAngle || raw.angle || raw.camera_angle);
const cameraMovement = normalizeText(
raw.cameraMovement || raw.cameraMove || raw.camera_motion || raw.motionType,
);
const focusDepth = normalizeText(
raw.focusDepth || raw.depthOfField || raw.focalDepth || raw.focus_depth,
);
const lighting = normalizeText(raw.lighting || raw.light || raw.lightDesign);
const backgroundMusic = normalizeText(
raw.backgroundMusic || raw.music || raw.bgm,
);
@ -135,10 +151,16 @@ function normalizeVideoAnalysisRows(
);
if (
!sceneDescription &&
!narrative &&
!backgroundMusic &&
!voiceAndSfx &&
!imagePrompt &&
!motionPrompt
!motionPrompt &&
!shotSize &&
!cameraAngle &&
!cameraMovement &&
!focusDepth &&
!lighting
) {
return null;
}
@ -147,7 +169,17 @@ function normalizeVideoAnalysisRows(
? Math.max(0, Math.min(framePreviews.length - 1, Math.round(keyframeIndex) - 1))
: index % Math.max(framePreviews.length, 1);
return {
shot: shot || String(index + 1),
startTime: startTime || (index === 0 ? "0" : ""),
endTime,
duration,
sceneDescription: sceneDescription || `镜头 ${index + 1}`,
narrative: narrative || sceneDescription || `镜头 ${index + 1}`,
shotSize: shotSize || "中景至近景",
cameraAngle: cameraAngle || "平视",
cameraMovement: cameraMovement || "缓慢推镜头 (Slow Zoom In)",
focusDepth: focusDepth || "浅景深",
lighting: lighting || "冷调低沉照明,主体光线柔和。",
backgroundMusic: backgroundMusic || "无",
voiceAndSfx: voiceAndSfx || "无",
imagePrompt: imagePrompt || sceneDescription || "延续当前视频气质生成关键帧",
@ -159,7 +191,17 @@ function normalizeVideoAnalysisRows(
(
item,
): item is {
shot: string;
startTime: string;
endTime: string;
duration: string;
sceneDescription: string;
narrative: string;
shotSize: string;
cameraAngle: string;
cameraMovement: string;
focusDepth: string;
lighting: string;
backgroundMusic: string;
voiceAndSfx: string;
imagePrompt: string;
@ -170,6 +212,226 @@ function normalizeVideoAnalysisRows(
.slice(0, 8);
}
function normalizeStoryShot(value: unknown, index: number) {
const text = normalizeText(value);
if (/^\d+$/.test(text)) return text.padStart(2, "0");
return text || String(index + 1).padStart(2, "0");
}
type NormalizedStoryScriptRow = {
shot: string;
duration: string;
scene: string;
sceneDescription: string;
shotSize: string;
lighting: string;
dialogue: string;
backgroundMusic: string;
voiceAndSfx: string;
cameraMovement: string;
imagePrompt: string;
motionPrompt: string;
keyframeUrl: string;
};
function normalizeStoryScriptRows(value: unknown): NormalizedStoryScriptRow[] {
if (!Array.isArray(value)) return [];
return value
.map((item, index) => {
if (Array.isArray(item)) {
const cells = item.map((cell) => normalizeText(cell));
const shot = normalizeStoryShot(cells[0], index);
const secondCellLooksLikeDuration = /^\d+(\.\d+)?\s*(s|秒)?$/i.test(cells[1] || "");
const duration = secondCellLooksLikeDuration ? cells[1] : "5s";
const scene = secondCellLooksLikeDuration ? cells[2] || `镜头 ${shot}` : cells[1] || `镜头 ${shot}`;
const sceneDescription = secondCellLooksLikeDuration ? cells[2] || scene : cells[2] || cells[3] || scene;
return {
shot,
duration,
scene,
sceneDescription,
shotSize: (secondCellLooksLikeDuration ? cells[3] : "") || "中景",
lighting: (secondCellLooksLikeDuration ? cells[4] : cells[4]) || "电影感光影",
dialogue: (secondCellLooksLikeDuration ? cells[5] : "") || "+",
backgroundMusic: "无",
voiceAndSfx: (secondCellLooksLikeDuration ? cells[6] : cells[3]) || "无",
cameraMovement: (secondCellLooksLikeDuration ? cells[7] : "") || "缓慢推镜头",
imagePrompt: (secondCellLooksLikeDuration ? cells[8] : cells[5]) || sceneDescription,
motionPrompt:
(secondCellLooksLikeDuration ? cells[9] || cells[7] : cells[6]) ||
"保持镜头运动自然、连贯,围绕主体动作推进。",
keyframeUrl: "",
};
}
const raw =
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>)
: {};
const shot = normalizeStoryShot(raw.shot || raw.shotNumber || raw.index || raw.id, index);
const duration = normalizeText(raw.duration || raw.length || raw.time || raw.seconds);
const scene = normalizeText(raw.scene || raw.location || raw.title || `镜头 ${shot}`);
const sceneDescription = normalizeText(
raw.sceneDescription ||
raw.description ||
raw.visual ||
raw.picture ||
raw.screen ||
raw.summary,
);
const shotSize = normalizeText(
raw.shotSize || raw.frameSize || raw.shot_type || raw.sceneScale || raw.cameraShot,
);
const lighting = normalizeText(
raw.lighting || raw.light || raw.atmosphere || raw.mood || raw.lightMood,
);
const dialogue = normalizeText(
raw.dialogue || raw.dialog || raw.narration || raw.voiceover || raw.monologue || raw.line,
);
const cameraMovement = normalizeText(
raw.cameraMovement || raw.cameraMove || raw.movement || raw.lensMovement || raw.motion,
);
const imagePrompt = normalizeText(
raw.imagePrompt ||
raw.image_generation_prompt ||
raw.image ||
raw.keyframePrompt ||
raw.keyFramePrompt,
);
const motionPrompt = normalizeText(
raw.motionPrompt ||
raw.videoPrompt ||
raw.video_motion_prompt ||
raw.motion ||
raw.cameraMovement,
);
const backgroundMusic = normalizeText(
raw.backgroundMusic || raw.music || raw.bgm || raw.soundtrack,
);
const voiceAndSfx = normalizeText(
raw.voiceAndSfx || raw.voice || raw.sound || raw.audio || raw.sfx,
);
if (
!scene &&
!sceneDescription &&
!imagePrompt &&
!motionPrompt &&
!backgroundMusic &&
!voiceAndSfx
) {
return null;
}
return {
shot,
duration: duration || "5s",
scene,
sceneDescription: sceneDescription || imagePrompt || scene || `镜头 ${shot}`,
shotSize: shotSize || "中景",
lighting: lighting || "电影感光影",
dialogue: dialogue || "+",
backgroundMusic: backgroundMusic || "无",
voiceAndSfx: voiceAndSfx || "无",
cameraMovement: cameraMovement || "缓慢推镜头",
imagePrompt: imagePrompt || sceneDescription || scene || `镜头 ${shot}关键帧`,
motionPrompt: motionPrompt || cameraMovement || "保持镜头运动自然、连贯,围绕主体动作推进。",
keyframeUrl: "",
};
})
.filter(
(
item,
): item is NormalizedStoryScriptRow => Boolean(item),
)
.slice(0, 18);
}
const storyExpansionPhases = [
{
description: "建立镜头,交代环境、人物位置和当前压力",
cameraMovement: "缓慢推镜头",
imagePrompt: "广角或中景建立画面,环境信息清晰,主体位置明确",
lighting: "环境主光明确,氛围基调清晰",
motionPrompt: "镜头缓慢推进或轻微横移,建立空间关系",
shotSize: "中景",
},
{
description: "动作推进,突出人物的关键行为或剧情转折",
cameraMovement: "跟随主体动作推进",
imagePrompt: "中景表现人物动作,服装、道具和光影保持连续",
lighting: "主体光影连续,动作区域清晰",
motionPrompt: "跟随主体动作推进,节奏自然连贯",
shotSize: "中景",
},
{
description: "情绪反应,用近景或特写强化人物心理和冲突",
cameraMovement: "轻微推近或稳定特写",
imagePrompt: "近景或特写,突出表情、眼神、手部或关键道具细节",
lighting: "面部光线突出,情绪反差增强",
motionPrompt: "轻微推近或稳定特写,强调情绪变化",
shotSize: "特写",
},
{
description: "关系与氛围,表现周围人物、环境反馈或声画张力",
cameraMovement: "横移扫过环境或切换视角",
imagePrompt: "加入环境反馈、群像反应或空间层次,电影感光影",
lighting: "场景氛围增强,空间层次明显",
motionPrompt: "镜头扫过环境或切换视角,保持节奏递进",
shotSize: "全景",
},
{
description: "转场收束,为下一镜头保留动作、视线或情绪钩子",
cameraMovement: "顺势后移、上摇或转场",
imagePrompt: "构图留出转场方向,动作或视线指向下一段剧情",
lighting: "光影引导视线并保留转场方向",
motionPrompt: "镜头后移、上摇或顺势转场,衔接下一镜头",
shotSize: "大远景",
},
];
function expandStoryScriptRowsToTarget(
rows: NormalizedStoryScriptRow[],
targetCount = 18,
): NormalizedStoryScriptRow[] {
if (rows.length === 0) return [];
if (rows.length >= targetCount) {
return rows.slice(0, targetCount).map((row, index) => ({
...row,
shot: String(index + 1).padStart(2, "0"),
}));
}
const baseCount = Math.floor(targetCount / rows.length);
const extraCount = targetCount % rows.length;
const expanded: NormalizedStoryScriptRow[] = [];
rows.forEach((row, rowIndex) => {
const segmentCount = baseCount + (rowIndex < extraCount ? 1 : 0);
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
const phase =
storyExpansionPhases[
Math.min(
storyExpansionPhases.length - 1,
Math.floor((segmentIndex / Math.max(segmentCount, 1)) * storyExpansionPhases.length),
)
];
expanded.push({
...row,
shot: String(expanded.length + 1).padStart(2, "0"),
sceneDescription: `${phase.description}:${row.sceneDescription}`,
shotSize: row.shotSize || phase.shotSize,
lighting: row.lighting || phase.lighting,
cameraMovement: row.cameraMovement || phase.cameraMovement,
imagePrompt: `${row.imagePrompt};${phase.imagePrompt}`,
motionPrompt: `${phase.motionPrompt};${row.motionPrompt}`,
});
}
});
return expanded.slice(0, targetCount);
}
function normalizeCharacters(value: unknown) {
if (!Array.isArray(value)) return [];
return value
@ -243,11 +505,21 @@ function buildVideoAnalysisPrompt(params: {
"highlights": ["3-5条镜头分析要点"],
"analysisRows": [
{
"sceneDescription": "镜头内容与景别描述",
"shot": "1",
"startTime": "0",
"endTime": "5",
"duration": "5",
"sceneDescription": "画面描述,说明主体、环境、动作、构图和色调",
"narrative": "叙事内容,说明这一镜头承担的剧情/情绪/信息作用",
"shotSize": "景别,例如中景至近景",
"cameraAngle": "摄影机角度,例如平视",
"cameraMovement": "摄影机运动,例如缓慢推镜头 (Slow Zoom In)",
"focusDepth": "焦距与景深,例如浅景深",
"lighting": "光线,例如冷调低沉照明,屏幕光作为侧逆光",
"backgroundMusic": "这一段适合的背景音乐氛围",
"voiceAndSfx": "人声/环境声/音效建议,没有就写无",
"imagePrompt": "后续生成关键帧时可直接使用的图像提示词",
"motionPrompt": "后续生成视频时可直接使用的运动提示词",
"imagePrompt": "后续生成关键帧时可直接使用的英文图像提示词",
"motionPrompt": "后续生成视频时可直接使用的英文运动提示词",
"keyframeIndex": 1
}
],
@ -257,7 +529,9 @@ function buildVideoAnalysisPrompt(params: {
"visualMotifs": ["2-4个可复用的视觉母题"],
"recommendedNextSteps": ["2-4条建议的下游动作"]
}`,
"analysisRows 请尽量输出 2-6 行;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
"analysisRows 请按镜头时间顺序输出;短视频可以输出 1 行,复杂视频输出 2-6 行。",
"每行必须覆盖镜号、开始时间、结束时间、时长、画面描述、叙事内容、景别、摄影机角度、摄影机运动、焦距与景深、光线、背景音乐、人声/音效、图像生成提示词、视频运动提示词和关键帧。",
"imagePrompt 和 motionPrompt 优先使用英文;其他字段使用中文;keyframeIndex 使用 1 开始的序号,对应你看到的关键帧顺序。",
"如果没有真实视频帧,只能依据参考图和脚本推断,也要正常输出,但措辞要稳健。",
]
.filter(Boolean)
@ -437,13 +711,63 @@ function buildAgentPlanPrompt(params: {
.join("\n");
}
function buildStoryScriptPrompt(params: {
nodeTitle?: string;
prompt: string;
imageCount: number;
videoCount: number;
}) {
return [
"你是视频画布里的故事脚本生成节点,需要把用户输入、参考图和参考视频整理成可直接用于后续分镜/生图/生视频的结构化脚本表。",
params.nodeTitle ? `当前节点:${params.nodeTitle}` : "",
`参考图数量:${params.imageCount}`,
`视频数量:${params.videoCount}`,
params.prompt ? `输入内容:\n${params.prompt}` : "没有额外脚本,请生成一个节奏清晰的短视频脚本。",
"",
"请只输出 JSON,不要输出 markdown,不要输出解释。",
"JSON 结构如下:",
`{
"summary": "一句话概括故事主题、情绪和视频方向,不超过120字",
"analysisRows": [
{
"shot": "01",
"duration": "5s",
"scene": "场景名或地点,如 盛唐·金銮殿·日",
"sceneDescription": "画面内容、人物动作和剧情推进,不要重复 scene 字段",
"shotSize": "景别,如中景、特写、全景、大远景",
"lighting": "光影氛围,如暖金主光带阴影,气氛紧张压抑",
"dialogue": "对白或旁白,没有就写 +",
"backgroundMusic": "背景音乐或情绪氛围,没有就写无",
"voiceAndSfx": "环境声、动作声或音效,没有就写无",
"cameraMovement": "运镜,如缓慢推镜头、固定、横移、上摇、环绕轨道跟拍",
"imagePrompt": "后续生成该镜头关键帧时可直接使用的中文图像提示词",
"motionPrompt": "后续生成视频时可直接使用的中文视频运动提示词"
}
],
"assetCandidates": {
"style": "全局视觉风格,如 盛唐·国风二次元",
"characters": ["需要生成的角色资产名"],
"scenes": ["需要生成的场景资产名"],
"props": ["需要生成的道具资产名"]
},
"shotIdeas": [
{ "title": "镜头 1", "description": "一句话描述镜头目标、景别或动作" }
],
"recommendedNextSteps": ["2-4条建议的下游动作"]
}`,
"analysisRows 必须直接输出 18 行,按镜头时间顺序编号 01-18;每行默认 5s 或 6s,剧情高潮可用 6s。",
"每行要覆盖竞品脚本表同等信息:镜号、时长、画面描述、景别、光影氛围、对白·旁白、音效、运镜、最终图像提示词和视频运动提示词。",
"画面描述中用 @角色名 或 @场景/道具名 标出可复用资产,例如 @沈昭昭、@金銮殿、@侍卫长刀。",
"imagePrompt 要像可直接生成分镜图的中文提示词,包含主体、环境、构图、光影、风格与一致性要求;motionPrompt 要按起始状态、动作过程、结束状态、音效组织。",
"assetCandidates 根据 18 镜头抽取 3-6 个角色、2-5 个场景和 1-4 个道具,方便后续准备资产。",
"每行要像专业视频画布脚本表:场景具体、画面可生成、声音可执行、镜头节奏明确,不要只写剧情摘要。",
]
.filter(Boolean)
.join("\n");
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const body = await request.json().catch(() => ({}));
const parsed = requestSchema.safeParse(body);
if (!parsed.success) {
@ -468,6 +792,11 @@ export async function POST(request: Request) {
);
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
}
const [allModels, agentModel] = await Promise.all([
getEnabledAiModelsWithProvider(false),
getAgentAiModelWithProvider(),
@ -534,7 +863,14 @@ export async function POST(request: Request) {
prompt,
imageCount: imageUrls.length,
})
: buildAgentPlanPrompt({
: parsed.data.mode === "story-script"
? buildStoryScriptPrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
imageCount: imageUrls.length,
videoCount: videoUrls.length,
})
: buildAgentPlanPrompt({
nodeTitle: normalizeText(parsed.data.nodeTitle),
prompt,
imageCount: imageUrls.length,
@ -568,7 +904,7 @@ export async function POST(request: Request) {
},
],
temperature: 0.2,
maxTokens: 1600,
maxTokens: parsed.data.mode === "story-script" ? 4200 : parsed.data.mode === "video-analysis" ? 2200 : 1600,
} as any);
const parsedJson = extractJsonBlock<Record<string, unknown>>(text) || {};
@ -619,6 +955,29 @@ export async function POST(request: Request) {
});
}
if (parsed.data.mode === "story-script") {
const analysisRows = expandStoryScriptRowsToTarget(normalizeStoryScriptRows(
parsedJson.analysisRows || parsedJson.storyRows || parsedJson.rows || parsedJson.shots,
));
return NextResponse.json({
success: true,
data: {
summary,
analysisRows,
shotIdeas:
normalizeShotIdeas(parsedJson.shotIdeas).length > 0
? normalizeShotIdeas(parsedJson.shotIdeas)
: analysisRows.map((row, index) => ({
title: `镜头 ${index + 1}`,
description: row.sceneDescription,
})),
recommendedNextSteps: normalizeStringList(parsedJson.recommendedNextSteps, 6),
model: selectedModel.model.displayName,
raw: text,
},
});
}
return NextResponse.json({
success: true,
data: {

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@ export type VideoCanvasBackendRoute =
| "enhance"
| "enhance-pro"
| "extend"
| "pick-object"
| "remove-bg"
| "layer-split"
| "local";
@ -38,11 +39,58 @@ export type VideoCanvasFunctionBillingCopy = {
description: string;
};
export type VideoCanvasEffectPreset = {
id: string;
title: string;
author: string;
usageCount: number;
model: string;
thumbnailUrl: string;
prompt: string;
commercial: boolean;
};
export type VideoCanvasMotionPreset = {
id: string;
name: string;
previewUrl: string;
prompt: string;
};
export type VideoCanvasRolePreset = {
id: string;
assetId: string;
name: string;
accent: string;
description: string;
prompt: string;
tags: string;
compliant: boolean;
images: {
expression: string;
face: string;
full: string;
threeView: string;
};
filters: {
age: string;
build: string;
culture: string;
era: string;
gender: string;
hairColor: string;
species: string;
};
};
export type VideoCanvasFunctionConfig = {
enabled: boolean;
defaultImageModel: string;
defaultVideoModel: string;
defaultMultimodalModel: string;
effectPresets: VideoCanvasEffectPreset[];
motionPresets: VideoCanvasMotionPreset[];
rolePresets: VideoCanvasRolePreset[];
functions: Record<string, VideoCanvasFunctionDefinition>;
};
@ -237,6 +285,18 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
prompt: "分析参考图片,输出结构化中文提示词,包括主体、环境、构图、光影、镜头语言和风格关键词。",
description: "多模态识图生成提示词。",
},
{
key: "element-mark",
label: "视频元素标记",
kind: "multimodal",
route: "pick-object",
enabled: true,
model: "qwen3-vl-plus",
prompt:
"图片中有一个红色圆形标记和十字线。请识别红色标记正中心所指向的视觉元素,并给出从宽泛主体到精确局部的可切换标签层级。若点中完整人物,首项用“人物”;若点中人物面部,首项用“人物面部”;若点中眼睛、手部等明确局部,可返回如 [\"人物\",\"眼睛\"] 的层级;商品或物体同理,先给完整主体,再给确实可见的局部。标签必须简短、客观、互不重复,每项不超过 8 个汉字,最多 4 项。严格返回纯 JSON:{\"name\":\"默认标签\",\"labels\":[\"默认标签\",\"更精确标签\"],\"position\":\"位置描述\",\"confidence\":0.95}。position 必须从以下选项中选择:{{positionOptions}}。不要输出 Markdown 或额外解释。",
description: "视频节点“标记”使用的点选元素识别规则;结果以可切换的结构化标签写入提示词。",
requiresBackend: true,
},
{
key: "text-to-video",
label: "文生视频",
@ -276,11 +336,335 @@ const defaultFunctions: VideoCanvasFunctionDefinition[] = [
},
];
export const defaultVideoCanvasEffectPresets: VideoCanvasEffectPreset[] = [
{
id: "bee-camera",
title: "小蜜蜂运镜",
author: "赛博蒲公英",
usageCount: 1100,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/bee-camera.webp",
prompt: "使用微型飞行器第一人称视角快速穿行场景,贴近主体掠过并保持连续、灵活的空间运动,形成小蜜蜂般轻盈迅捷的运镜。",
commercial: true,
},
{
id: "cloud-dive",
title: "穿云而入",
author: "数据星云",
usageCount: 528,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/cloud-dive.webp",
prompt: "镜头从高空云层快速向下俯冲,穿透云雾后自然揭示参考图主体和场景,保持方向连续并强化速度与纵深感。",
commercial: true,
},
{
id: "horizon-flight",
title: "飞跃地平线",
author: "赛博蒲公英",
usageCount: 1000,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/horizon-flight.webp",
prompt: "镜头低空高速前进并在接近地平线时抬升飞越,空间尺度逐步展开,主体和环境运动保持稳定连贯。",
commercial: true,
},
{
id: "reverse-gravity",
title: "逆转引力",
author: "量子呢喃",
usageCount: 239,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/reverse-gravity.webp",
prompt: "让场景和主体在保持结构一致的前提下出现引力反转,镜头随空间翻转并平滑过渡,避免突然跳切和形体崩坏。",
commercial: true,
},
{
id: "earth-zoom",
title: "地球缩放",
author: "数字游隼",
usageCount: 180,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/earth-zoom.webp",
prompt: "从参考场景连续快速拉远到城市、云层和地球尺度,保持地理方向和中心主体一致,形成无缝地球缩放转场。",
commercial: true,
},
{
id: "global-zoom",
title: "环球缩放",
author: "比特浮尘",
usageCount: 184,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/global-zoom.webp",
prompt: "围绕地球尺度完成连续环绕和缩放,镜头从局部场景扩展至全球视野后平滑落回目标区域,运动轨迹清晰稳定。",
commercial: true,
},
{
id: "iris-push",
title: "瞳孔推镜",
author: "参数迷宫",
usageCount: 387,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/iris-push.webp",
prompt: "镜头平滑推近人物眼睛并穿入瞳孔,以瞳孔纹理作为转场中心,保持面部身份稳定并避免眼部畸变。",
commercial: true,
},
{
id: "earth-dive",
title: "俯冲地球",
author: "神经元旋涡",
usageCount: 275,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/earth-dive.webp",
prompt: "从太空视角向地球目标区域高速俯冲,依次穿过大气、云层和城市,最终自然抵达参考画面。",
commercial: true,
},
{
id: "product-light-sweep",
title: "产品扫光",
author: "模型幻景",
usageCount: 943,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/product-light-sweep.webp",
prompt: "在不改变产品造型、材质和品牌细节的前提下,让精致轮廓光从一侧平滑扫过产品表面,突出质感与高光细节。",
commercial: true,
},
{
id: "prada-change",
title: "普拉达换装",
author: "量子呢喃",
usageCount: 71,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/prada-change.webp",
prompt: "保持人物身份、姿态和镜头不变,以高级时装广告节奏完成连续换装,服装材质真实且每次变化衔接自然。",
commercial: true,
},
{
id: "multi-angle",
title: "多角度定点",
author: "代码极光",
usageCount: 546,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/multi-angle.webp",
prompt: "围绕主体在多个固定机位间平滑切换,主体位置和比例保持稳定,每个角度清晰展示不同侧面并避免身份漂移。",
commercial: true,
},
{
id: "underwater-slow",
title: "水下慢镜头",
author: "代码极光",
usageCount: 253,
model: "Seedance 2.0",
thumbnailUrl: "/video-canvas/effects/underwater-slow.webp",
prompt: "将动作转为水下慢镜头质感,加入自然漂浮、细微气泡和柔和水体折射,保持主体外观清晰并让运动流畅连贯。",
commercial: true,
},
...[
["eye-makeup", "试妆特写", "像素漫游者", 98, "iris-push.webp"],
["float-in", "悬浮缓入", "像素漫游者", 171, "cloud-dive.webp"],
["macro-push", "微距推镜", "智能碎屑", 273, "product-light-sweep.webp"],
["helicopter-reveal", "直升机揭幕", "认知碎片", 100, "horizon-flight.webp"],
["mountain-chase", "山路追击", "代码极光", 185, "multi-angle.webp"],
["snow-race", "雪地赛车", "智能碎屑", 102, "earth-dive.webp"],
["city-drive", "City Drive", "像素漫游者", 70, "bee-camera.webp"],
["deconstruct-3d", "3D解构", "算法织梦者", 296, "reverse-gravity.webp"],
["face-orbit", "面部环拍", "比特浮尘", 183, "multi-angle.webp"],
["showroom", "Showroom", "智能碎屑", 65, "product-light-sweep.webp"],
["jewelry-closeup", "饰品特写", "逻辑褶皱", 62, "product-light-sweep.webp"],
["giant-lookdown", "巨人俯瞰", "算法织梦者", 116, "reverse-gravity.webp"],
["runway", "Runway", "算力潮汐", 81, "horizon-flight.webp"],
["ai-dance", "AI 编舞", "比特浮尘", 501, "multi-angle.webp"],
["android", "机械姬", "认知碎片", 137, "product-light-sweep.webp"],
["star-scene", "巨星名场面", "数据星云", 80, "horizon-flight.webp"],
["mirror-double", "镜面分身", "赛博蒲公英", 56, "reverse-gravity.webp"],
["iris-mutation", "瞳孔异变", "数字游隼", 50, "iris-push.webp"],
].map(([id, title, author, usageCount, thumbnail]) => ({
id: String(id),
title: String(title),
author: String(author),
usageCount: Number(usageCount),
model: "Seedance 2.0",
thumbnailUrl: `/video-canvas/effects/${String(thumbnail)}`,
prompt: `保持参考主体和画面结构稳定,应用“${String(title)}”的专业视频特效与镜头运动,过渡自然、动作连续、避免形体畸变和闪烁。`,
commercial: true,
})),
];
const defaultMotionPreviewUrls = {
still: "/video-canvas/effects/product-light-sweep.webp",
follow: "/video-canvas/effects/bee-camera.webp",
orbit: "/video-canvas/effects/reverse-gravity.webp",
tilt: "/video-canvas/effects/cloud-dive.webp",
pan: "/video-canvas/effects/multi-angle.webp",
rise: "/video-canvas/effects/horizon-flight.webp",
descend: "/video-canvas/effects/underwater-slow.webp",
push: "/video-canvas/effects/iris-push.webp",
pull: "/video-canvas/effects/earth-zoom.webp",
zoom: "/video-canvas/effects/global-zoom.webp",
} as const;
export const defaultVideoCanvasMotionPresets: VideoCanvasMotionPreset[] = [
{ id: "fixed", name: "固定镜头", previewUrl: defaultMotionPreviewUrls.still, prompt: "固定镜头" },
{ id: "follow", name: "跟随拍摄", previewUrl: defaultMotionPreviewUrls.follow, prompt: "跟随拍摄" },
{ id: "orbit-up", name: "盘旋抬升", previewUrl: defaultMotionPreviewUrls.orbit, prompt: "盘旋抬升" },
{ id: "orbit-down", name: "盘旋下降", previewUrl: defaultMotionPreviewUrls.orbit, prompt: "盘旋下降" },
{ id: "tilt-up", name: "镜头上摇", previewUrl: defaultMotionPreviewUrls.tilt, prompt: "镜头上摇" },
{ id: "tilt-down", name: "镜头下摇", previewUrl: defaultMotionPreviewUrls.tilt, prompt: "镜头下摇" },
{ id: "pan-left", name: "镜头左摇", previewUrl: defaultMotionPreviewUrls.pan, prompt: "镜头左摇" },
{ id: "pan-right", name: "镜头右摇", previewUrl: defaultMotionPreviewUrls.pan, prompt: "镜头右摇" },
{ id: "rise", name: "镜头上升", previewUrl: defaultMotionPreviewUrls.rise, prompt: "镜头上升" },
{ id: "descend", name: "镜头下降", previewUrl: defaultMotionPreviewUrls.descend, prompt: "镜头下降" },
{ id: "truck-left", name: "镜头左移", previewUrl: defaultMotionPreviewUrls.pan, prompt: "镜头左移" },
{ id: "truck-right", name: "镜头右移", previewUrl: defaultMotionPreviewUrls.pan, prompt: "镜头右移" },
{ id: "push-in", name: "镜头前推", previewUrl: defaultMotionPreviewUrls.push, prompt: "镜头前推" },
{ id: "pull-out", name: "镜头后移", previewUrl: defaultMotionPreviewUrls.pull, prompt: "镜头后移" },
{ id: "zoom-in", name: "变焦推进", previewUrl: defaultMotionPreviewUrls.push, prompt: "变焦推进" },
{ id: "zoom-out", name: "变焦拉远", previewUrl: defaultMotionPreviewUrls.zoom, prompt: "变焦拉远" },
{ id: "dolly-zoom", name: "柯克变焦", previewUrl: defaultMotionPreviewUrls.zoom, prompt: "柯克变焦" },
{ id: "orbit", name: "环绕拍摄", previewUrl: defaultMotionPreviewUrls.orbit, prompt: "环绕拍摄" },
{ id: "roll", name: "滚筒旋转", previewUrl: defaultMotionPreviewUrls.orbit, prompt: "滚筒旋转" },
{ id: "first-person", name: "第一视角", previewUrl: defaultMotionPreviewUrls.follow, prompt: "第一视角" },
];
const defaultRoleImages: VideoCanvasRolePreset["images"] = {
expression:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
face:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
full:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-image.png",
threeView:
"https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/video-canvas-demo/character-three-view.png",
};
function createDefaultRolePreset(
input: Omit<VideoCanvasRolePreset, "assetId" | "compliant" | "images">,
): VideoCanvasRolePreset {
return {
...input,
assetId: `public-role:${input.id}`,
compliant: true,
images: { ...defaultRoleImages },
};
}
export const defaultVideoCanvasRolePresets: VideoCanvasRolePreset[] = [
createDefaultRolePreset({
id: "fresh-girl",
name: "甜妹/清新少女",
accent: "linear-gradient(135deg,#ffd8df,#ffd4a8)",
description: "甜妹/清新少女,含角色立绘、脸部近景、表情参考与三视图。",
prompt: "保持清新少女的五官、发型、浅色穿搭和温柔气质稳定,动作自然,所有镜头中身份一致。",
tags: "女主 女 现代 青年 温柔",
filters: { age: "青年", build: "匀称", culture: "东亚", era: "现代", gender: "女", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "executive-man",
name: "霸总/精英大佬",
accent: "linear-gradient(135deg,#d6e3ff,#a8b5ff)",
description: "精英大佬/霸总角色,西装、冷静、掌控感强。",
prompt: "保持都市精英男性的面部、黑发、深色西装和冷静强势气质稳定,避免身份与服装漂移。",
tags: "男主 男 现代 青年 精英",
filters: { age: "青年", build: "健壮", culture: "东亚", era: "现代", gender: "男", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "gentle-mature-man",
name: "温柔熟男/理想男友",
accent: "linear-gradient(135deg,#f6e6c9,#d4b98b)",
description: "成熟、稳定、生活感强,适合情感叙事和陪伴感镜头。",
prompt: "保持成熟温柔男性的面部、发型、针织穿搭和温暖眼神一致,表演自然克制。",
tags: "男主 男 现代 熟男 温柔",
filters: { age: "中年", build: "匀称", culture: "东亚", era: "现代", gender: "男", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "cool-heiress",
name: "清冷千金/白切黑女主",
accent: "linear-gradient(135deg,#f2f4ff,#bcc8e8)",
description: "外表克制优雅、内在反差强,适合悬疑和都市情节。",
prompt: "保持清冷女性的五官、黑发、浅色套装和克制表情一致,突出优雅与反差感。",
tags: "女主 女 现代 青年 清冷",
filters: { age: "青年", build: "匀称", culture: "东亚", era: "现代", gender: "女", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "ancient-male-lead",
name: "古风男主",
accent: "linear-gradient(135deg,#d9c7a8,#8f6d47)",
description: "长袍束发、侠气和权谋感兼具,适合宫廷、江湖和玄幻故事。",
prompt: "保持古风男性的五官、束发、长袍材质和沉稳英气一致,动作符合古装角色身份。",
tags: "男主 男 古风 青年 英气",
filters: { age: "青年", build: "健壮", culture: "东亚", era: "古代", gender: "男", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "ancient-female-lead",
name: "古风女主",
accent: "linear-gradient(135deg,#ffdce8,#b7d9ff)",
description: "柔美但有行动力,适合穿越、宫廷、仙侠和小说推文分镜。",
prompt: "保持古风女性的五官、精致发髻、浅色罗裙和柔美坚定气质一致,避免妆造漂移。",
tags: "女主 女 古风 青年 柔美",
filters: { age: "青年", build: "匀称", culture: "东亚", era: "古代", gender: "女", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "villainess",
name: "恶毒女配/白莲花",
accent: "linear-gradient(135deg,#ffe9f1,#e4b8d5)",
description: "表情管理强,适合冲突镜头、反派转折和情绪戏。",
prompt: "保持精致女性反派的身份、妆发和华丽服装一致,用细微表情表现柔弱外表与反派反差。",
tags: "女配 女 现代 反派 戏剧",
filters: { age: "青年", build: "匀称", culture: "东亚", era: "现代", gender: "女", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "lifestyle-person",
name: "生活方式普通人",
accent: "linear-gradient(135deg,#e7f2d3,#b6d49a)",
description: "真实自然、易代入,适合口播、日常剧情和产品场景。",
prompt: "保持普通生活方式人物的身份、自然穿搭和真实表情一致,避免过度精修与商业模特感。",
tags: "普通人 现代 日常 真实",
filters: { age: "青年", build: "匀称", culture: "东亚", era: "现代", gender: "中性", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "fashion-asian-man",
name: "时尚感亚洲男生",
accent: "linear-gradient(135deg,#d7f0ff,#8dc5e8)",
description: "潮流、干净、镜头表现力强,适合品牌短片和社媒内容。",
prompt: "保持时尚亚洲男性的面部、妆发和潮流穿搭一致,呈现干净自信的商业镜头表现。",
tags: "男 亚洲 时尚 青年",
filters: { age: "青年", build: "匀称", culture: "东亚", era: "现代", gender: "男", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "fashion-asian-woman",
name: "时尚感亚洲女生",
accent: "linear-gradient(135deg,#ffe2ef,#ffd0bc)",
description: "造型感强,适合妆造、服饰、电商和生活方式短片。",
prompt: "保持时尚亚洲女性的五官、精致妆发和潮流穿搭一致,姿态自信,服饰细节稳定。",
tags: "女 亚洲 时尚 青年",
filters: { age: "青年", build: "匀称", culture: "东亚", era: "现代", gender: "女", hairColor: "黑发", species: "人类" },
}),
createDefaultRolePreset({
id: "fashion-western-man",
name: "时尚感欧美男生",
accent: "linear-gradient(135deg,#dde7ff,#c7d0dc)",
description: "轮廓鲜明,适合国际化品牌、运动、科技和旅拍内容。",
prompt: "保持时尚欧美男性的鲜明轮廓、发型和现代穿搭一致,动作有品牌广告的克制力量感。",
tags: "男 欧美 时尚 青年",
filters: { age: "青年", build: "健壮", culture: "欧美", era: "现代", gender: "男", hairColor: "棕发", species: "人类" },
}),
createDefaultRolePreset({
id: "fashion-western-woman",
name: "时尚感欧美女生",
accent: "linear-gradient(135deg,#ffe5f5,#d8c2ff)",
description: "适合美妆、服饰、旅行和高质感商业镜头。",
prompt: "保持时尚欧美女性的五官、精致造型和现代服饰一致,呈现明亮自信的商业大片质感。",
tags: "女 欧美 时尚 青年",
filters: { age: "青年", build: "匀称", culture: "欧美", era: "现代", gender: "女", hairColor: "棕发", species: "人类" },
}),
];
export const defaultVideoCanvasFunctionConfig: VideoCanvasFunctionConfig = {
enabled: true,
defaultImageModel: "auto",
defaultVideoModel: "auto",
defaultMultimodalModel: "auto",
effectPresets: defaultVideoCanvasEffectPresets,
motionPresets: defaultVideoCanvasMotionPresets,
rolePresets: defaultVideoCanvasRolePresets,
functions: Object.fromEntries(defaultFunctions.map((item) => [item.key, item])),
};
@ -404,6 +788,109 @@ export function normalizeVideoCanvasFunctionConfig(value: unknown): VideoCanvasF
typeof value.defaultMultimodalModel === "string" && value.defaultMultimodalModel.trim()
? value.defaultMultimodalModel.trim()
: defaultVideoCanvasFunctionConfig.defaultMultimodalModel,
effectPresets: Array.isArray(value.effectPresets) && value.effectPresets.length > 0
? value.effectPresets
.slice(0, 100)
.map((item, index) => {
const incoming = isRecord(item) ? item : {};
const fallback =
defaultVideoCanvasEffectPresets[index] ||
defaultVideoCanvasEffectPresets[0];
return {
id: String(incoming.id || fallback.id || `effect-${index + 1}`),
title: String(incoming.title || fallback.title || `视频特效 ${index + 1}`),
author: String(incoming.author || fallback.author || "官方预设"),
usageCount: Math.max(
0,
Math.round(Number(incoming.usageCount ?? fallback.usageCount) || 0),
),
model: String(incoming.model || fallback.model || "Seedance 2.0"),
thumbnailUrl: String(
incoming.thumbnailUrl || fallback.thumbnailUrl || "",
),
prompt: String(incoming.prompt || fallback.prompt || ""),
commercial:
typeof incoming.commercial === "boolean"
? incoming.commercial
: fallback.commercial,
} satisfies VideoCanvasEffectPreset;
})
.filter((item) => item.title && item.thumbnailUrl)
: defaultVideoCanvasEffectPresets,
motionPresets: Array.isArray(value.motionPresets) && value.motionPresets.length > 0
? value.motionPresets
.slice(0, 100)
.map((item, index) => {
const incoming = isRecord(item) ? item : {};
const fallback =
defaultVideoCanvasMotionPresets[index] ||
defaultVideoCanvasMotionPresets[0];
return {
id: String(incoming.id || fallback.id || `motion-${index + 1}`),
name: String(incoming.name || fallback.name || `运镜 ${index + 1}`),
previewUrl: String(
incoming.previewUrl || fallback.previewUrl || "",
),
prompt: String(incoming.prompt || fallback.prompt || ""),
} satisfies VideoCanvasMotionPreset;
})
.filter((item) => item.name && item.previewUrl)
: defaultVideoCanvasMotionPresets,
rolePresets: Array.isArray(value.rolePresets) && value.rolePresets.length > 0
? value.rolePresets
.slice(0, 100)
.map((item, index) => {
const incoming = isRecord(item) ? item : {};
const fallback =
defaultVideoCanvasRolePresets[index] ||
defaultVideoCanvasRolePresets[0];
const incomingImages = isRecord(incoming.images) ? incoming.images : {};
const incomingFilters = isRecord(incoming.filters) ? incoming.filters : {};
return {
id: String(incoming.id || fallback.id || `role-${index + 1}`),
assetId: String(
incoming.assetId || fallback.assetId || `public-role:role-${index + 1}`,
),
name: String(incoming.name || fallback.name || `角色 ${index + 1}`),
accent: String(incoming.accent || fallback.accent || "#525252"),
description: String(incoming.description || fallback.description || ""),
prompt: String(incoming.prompt || fallback.prompt || ""),
tags: String(incoming.tags || fallback.tags || ""),
compliant:
typeof incoming.compliant === "boolean"
? incoming.compliant
: fallback.compliant,
images: {
expression: String(
incomingImages.expression || fallback.images.expression || "",
),
face: String(incomingImages.face || fallback.images.face || ""),
full: String(incomingImages.full || fallback.images.full || ""),
threeView: String(
incomingImages.threeView || fallback.images.threeView || "",
),
},
filters: {
age: String(incomingFilters.age || fallback.filters.age || ""),
build: String(incomingFilters.build || fallback.filters.build || ""),
culture: String(
incomingFilters.culture || fallback.filters.culture || "",
),
era: String(incomingFilters.era || fallback.filters.era || ""),
gender: String(
incomingFilters.gender || fallback.filters.gender || "",
),
hairColor: String(
incomingFilters.hairColor || fallback.filters.hairColor || "",
),
species: String(
incomingFilters.species || fallback.filters.species || "",
),
},
} satisfies VideoCanvasRolePreset;
})
.filter((item) => item.name && item.images.full)
: defaultVideoCanvasRolePresets,
functions,
};
}

View File

@ -102,6 +102,7 @@ export interface CanvasTextSuggestionItem {
export type WorkflowAnalysisMode =
| "video-analysis"
| "character-scene-extraction"
| "story-script"
| "agent-plan";
export interface EnhanceProImageResult {
@ -1002,7 +1003,18 @@ export const workflowAnalysisService = {
}): Promise<
ApiResponse<{
analysisRows?: Array<{
shot?: string | number;
startTime?: string | number;
endTime?: string | number;
duration?: string | number;
sceneDescription?: string;
narrative?: string;
story?: string;
shotSize?: string;
cameraAngle?: string;
cameraMovement?: string;
focusDepth?: string;
lighting?: string;
backgroundMusic?: string;
voiceAndSfx?: string;
imagePrompt?: string;