feat: update video canvas frontend workflow
This commit is contained in:
parent
a5995ca0c4
commit
0d67e2935c
@ -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>
|
||||
|
||||
@ -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
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user