2081 lines
93 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
defaultVideoCanvasDirectorPosePresets,
type VideoCanvasDirectorPosePreset,
} from "@/lib/video-canvas/director-pose-presets";
import {
defaultVideoCanvasDirectorCameraPresets,
type VideoCanvasDirectorCameraPreset,
} from "@/lib/video-canvas/director-camera-presets";
import {
defaultVideoCanvasVideoHdConfig,
normalizeVideoCanvasVideoHdConfig,
type VideoCanvasVideoHdConfig,
} from "@/lib/video-canvas/video-hd";
import { DEFAULT_VIDEO_ANALYSIS_COST } from "@/lib/video-canvas/video-analysis";
import {
defaultVideoCanvasSubtitleEraseConfig,
normalizeVideoCanvasSubtitleEraseConfig,
type VideoCanvasSubtitleEraseConfig,
} from "@/lib/video-canvas/subtitle-erase";
import {
defaultVideoCanvasAudioSeparationConfig,
normalizeVideoCanvasAudioSeparationConfig,
type VideoCanvasAudioSeparationConfig,
} from "@/lib/video-canvas/video-audio-separation";
import {
defaultVideoCanvasVideoDownloadConfig,
normalizeVideoCanvasVideoDownloadConfig,
type VideoCanvasVideoDownloadConfig,
} from "@/lib/video-canvas/video-download";
import {
defaultVideoCanvasGenerationParametersConfig,
normalizeVideoCanvasGenerationParametersConfig,
type VideoCanvasGenerationParametersConfig,
} from "@/lib/video-canvas/video-generation-parameters";
import {
defaultVideoCanvasModelCatalog,
normalizeVideoCanvasModelCatalog,
type VideoCanvasModelCatalogEntry,
} from "@/lib/video-canvas/video-model-catalog";
import {
defaultVideoCanvasToolboxConfig,
normalizeVideoCanvasToolboxConfig,
type VideoCanvasToolboxConfig,
} from "@/lib/video-canvas/toolbox-workflows";
import {
defaultVideoCanvasHistoryPickerConfig,
isVideoCanvasHistoryZoomPercent,
normalizeVideoCanvasHistoryPickerConfig,
type VideoCanvasHistoryPickerConfig,
type VideoCanvasHistoryZoomPercent,
} from "@/lib/video-canvas/history-assets";
import {
defaultVideoCanvasTutorialSupportConfig,
normalizeVideoCanvasTutorialSupportConfig,
type VideoCanvasTutorialSupportConfig,
} from "@/lib/video-canvas/tutorial-support";
import {
defaultVideoCanvasNodeCreationConfig,
normalizeVideoCanvasNodeCreationConfig,
type VideoCanvasNodeCreationConfig,
} from "@/lib/video-canvas/node-creation";
export const VIDEO_CANVAS_FUNCTION_CONFIG_KEY = "video_canvas_function_config";
export const VIDEO_CANVAS_DIRECTOR_ASPECT_RATIOS = [
"auto",
"21:9",
"16:9",
"4:3",
"1:1",
"3:4",
"9:16",
] as const;
export type VideoCanvasDirectorAspectRatio =
(typeof VIDEO_CANVAS_DIRECTOR_ASPECT_RATIOS)[number];
export function isVideoCanvasDirectorAspectRatio(
value: unknown,
): value is VideoCanvasDirectorAspectRatio {
return VIDEO_CANVAS_DIRECTOR_ASPECT_RATIOS.includes(
value as VideoCanvasDirectorAspectRatio,
);
}
export type VideoCanvasFunctionKind =
| "multimodal"
| "image"
| "image-edit"
| "video"
| "audio"
| "utility"
| "workflow";
export type VideoCanvasBackendRoute =
| "canvas-generate-image"
| "canvas-generate-video"
| "canvas-prompt-tools"
| "canvas-workflow-analyze"
| "director-ai-import"
| "angle-edit"
| "enhance"
| "enhance-pro"
| "extend"
| "pick-object"
| "remove-bg"
| "layer-split"
| "local";
export type VideoCanvasFunctionDefinition = {
key: string;
label: string;
kind: VideoCanvasFunctionKind;
route: VideoCanvasBackendRoute;
enabled: boolean;
cost?: number;
model?: string;
prompt: string;
generationPrompt?: string;
description: string;
requiresBackend?: boolean;
};
export type VideoCanvasFunctionBillingCopy = {
shortLabel: string;
description: string;
};
export type VideoCanvasEffectPreset = {
id: string;
title: string;
author: string;
authorAvatarUrl: string;
usageCount: number;
model: string;
thumbnailUrl: string;
prompt: string;
searchKeywords: string;
commercial: boolean;
featured: 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 VideoCanvasSubjectVoicePreset = {
id: string;
name: string;
badge: string;
previewUrl: string;
};
export type VideoCanvasSubjectLibraryConfig = {
enabled: boolean;
historyLibraryEnabled: boolean;
imageAccept: string;
imageMinCount: number;
imageMaxCount: number;
imageMaxFileSizeMb: number;
videoAccept: string;
videoMinDurationSeconds: number;
videoMaxDurationSeconds: number;
videoMaxFileSizeMb: number;
nameMaxLength: number;
descriptionMaxLength: number;
requireClearFrontFace: boolean;
unselectedCategory: string;
voiceHelpText: string;
labels: string[];
voices: VideoCanvasSubjectVoicePreset[];
smartDescription: {
cost: number;
enabled: boolean;
maxTokens: number;
prompt: string;
temperature: number;
};
smartCompletion: {
aspectRatio: string;
candidateCount: number;
cost: number;
enabled: boolean;
imageSize: string;
maxSelected: number;
model: string;
prompt: string;
};
videoUsage: {
enabled: boolean;
maxSubjects: number;
modelPatterns: string[];
promptTemplate: string;
};
};
export type VideoCanvasSmartStoryboardConfig = {
defaultShotDurationSeconds: number;
enabled: boolean;
maxDescriptionLength: number;
maxShots: number;
maxTotalDurationSeconds: number;
minShotDurationSeconds: number;
minTotalDurationSeconds: number;
modelPatterns: string[];
promptTemplate: string;
shotPromptTemplate: string;
};
export type VideoCanvasReferenceMode = "first-frame" | "reference-images";
export type VideoCanvasReferenceBehavior = {
autoExit: boolean;
hideActionAtLimit: boolean;
maxImages: number;
mode: VideoCanvasReferenceMode;
};
export type VideoCanvasReferenceModelRule = VideoCanvasReferenceBehavior & {
modelPattern: string;
};
export type VideoCanvasReferenceSelectionConfig = {
defaultRule: VideoCanvasReferenceBehavior;
modelRules: VideoCanvasReferenceModelRule[];
};
export type VideoCanvasAdvancedSettingsConfig = {
defaultAutoValidateMaterial: boolean;
defaultSearchEnabled: boolean;
};
export type VideoCanvasHistoryAssetsConfig = {
defaultZoomPercent: VideoCanvasHistoryZoomPercent;
enabled: boolean;
maxItems: number;
picker: VideoCanvasHistoryPickerConfig;
previewAutoplay: boolean;
showWatermarkBadge: boolean;
watermarkBadgeText: string;
};
export type VideoCanvasDirectorDeskConfig = {
aiImport: {
cost: number;
enabled: boolean;
functionKey: string;
historyLimit: number;
localSamEndpoint: string;
uploadAccept: string;
};
cameraPresets: VideoCanvasDirectorCameraPreset[];
defaultAspectRatio: VideoCanvasDirectorAspectRatio;
enabled: boolean;
embedUrl: string;
gaussian: {
maxFileSizeMb: number;
minRaycastOpacity: number;
uploadAccept: string;
};
crowd: {
defaultColumns: number;
defaultRows: number;
defaultSpacing: number;
};
helpUrl: string;
panorama: {
aiAspectRatio: string;
aiCost: number;
aiFunctionKey: string;
aiImageSize: string;
aiPreprocessDelayMs: number;
historyLimit: number;
uploadAccept: string;
};
posePresets: VideoCanvasDirectorPosePreset[];
showBatchCaptureControls: boolean;
};
export type VideoCanvasFunctionConfig = {
enabled: boolean;
defaultImageModel: string;
defaultVideoModel: string;
defaultMultimodalModel: string;
directorDesk: VideoCanvasDirectorDeskConfig;
effectPresets: VideoCanvasEffectPreset[];
historyAssets: VideoCanvasHistoryAssetsConfig;
motionPresets: VideoCanvasMotionPreset[];
nodeCreation: VideoCanvasNodeCreationConfig;
rolePresets: VideoCanvasRolePreset[];
smartStoryboard: VideoCanvasSmartStoryboardConfig;
subjectLibrary: VideoCanvasSubjectLibraryConfig;
toolbox: VideoCanvasToolboxConfig;
tutorialSupport: VideoCanvasTutorialSupportConfig;
videoAdvancedSettings: VideoCanvasAdvancedSettingsConfig;
videoGenerationParameters: VideoCanvasGenerationParametersConfig;
videoModelCatalog: VideoCanvasModelCatalogEntry[];
videoReferenceSelection: VideoCanvasReferenceSelectionConfig;
videoAudioSeparation: VideoCanvasAudioSeparationConfig;
videoDownload: VideoCanvasVideoDownloadConfig;
videoHd: VideoCanvasVideoHdConfig;
videoSubtitleErase: VideoCanvasSubtitleEraseConfig;
functions: Record<string, VideoCanvasFunctionDefinition>;
};
const defaultFunctions: VideoCanvasFunctionDefinition[] = [
{
key: "storyboard-image",
label: "故事板图片生成",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"根据故事板镜头描述生成单张画面。严格保持引用角色、场景、服装、道具和视觉风格的一致性,准确执行景别、机位、构图、动作、光线和时间要求;画面中不要出现提示词文字、边框、水印或无关元素。",
description: "故事板普通图片节点使用的可配置图片生成入口。",
},
{
key: "blocking-storyboard",
label: "调度故事板",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"基于镜头描述生成带人物站位、运动方向、视线、摄影机位置和运动轨迹的调度草图分镜,标记清楚但不要改变角色与场景设定。",
description: "生成带有运动轨迹等调度草图分镜。",
},
{
key: "narrative-storyboard",
label: "故事板",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"根据镜头描述和参考素材生成完整剧情片段故事板,保持人物、场景、道具、时间与视觉风格连续一致。",
description: "生成完整剧情片段。",
},
{
key: "portrait-texture-adjustment",
label: "人像质感调节",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"保持人物身份、五官、服装、姿态与构图不变,降低 AI 塑料感,优化皮肤、发丝、布料、自然光影与真实摄影质感。",
description: "降低 AI 感,优化人物质感与光影。",
},
{
key: "character-design-sheet",
label: "角色设定图",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"根据参考角色生成角色主视觉与设定拆解,保持身份、五官、发型、服装、配色和体型比例一致,排版清晰,不添加无关角色。",
description: "角色主视觉与设定拆解。",
},
{
key: "character-face-three-view",
label: "角色脸部三视图",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"基于一张角色参考图生成同一角色的脸部正面、侧面和四分之三侧面细节三视图,严格保持五官、发型、肤色、妆容和年龄一致,干净背景,禁止额外人物和文字。",
description: "基于一张参考图生成脸部细节三视图。",
},
{
key: "scene-design-sheet",
label: "场景设定图",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"根据参考场景生成场景主视觉与空间、建筑、材质、道具、光线设定拆解,保持世界观和视觉风格一致。",
description: "场景主视觉与设定拆解。",
},
{
key: "product-design-sheet",
label: "产品设定图",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"根据参考产品生成产品主视觉与结构、材质、细节、配色和多角度设定拆解,保持产品外观和品牌语言一致。",
description: "产品主视觉与设定拆解。",
},
{
key: "story-beat-grid-4",
label: "剧情推演四宫格",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。画面中只能有 4 个画面区域,只允许一条竖向分割线和一条横向分割线;严禁出现 3 行 3 列、9 个画面区域或九宫格布局。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。",
description: "官方剧情推演四宫格:基于参考图生成 2x2 连续剧情分镜合成图。",
},
{
key: "character-three-view",
label: "角色三视图",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"以选中图片作为唯一角色参考,生成一张角色三视图设定图。只输出正面、侧面、背面 3 个完整站姿人物,三人横向并排在同一张 16:9 画面中,白色或浅灰干净背景。必须保持同一角色身份、五官、发型、绿色服装、材质、体型比例和配饰一致;正面、侧面、背面视角要清晰可辨,人物全身完整入画,站姿自然。禁止生成 3x3 九宫格、多机位分镜、黑色分割线、重复头像、文字、水印、编号、环境场景或额外人物。",
description: "官方角色三视图预设,使用参考图生图/编辑模型。",
},
{
key: "multi-view-nine-grid",
label: "多机位九宫格",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
description: "官方九宫格工具:先生成带黑色分割线的 3x3 分镜图,再由宫格切分拆成单张镜头。",
},
{
key: "continuous-storyboard-grid-25",
label: "25宫格连贯分镜",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,必须严格 5x5 等分排版,25 个宫格的宽高一致、间距一致、边界完整,所有宫格之间使用清晰纯黑分割线,分割线等宽且横线竖线都要贯穿整张图;禁止使用白线、浅色线、柔边、渐变线、不规则拼贴、错位排版、跨格大图、留白边距、文字、水印、编号或额外边框说明。",
description: "官方 25 宫格连贯分镜:生成 5x5 连续镜头合成图。",
},
{
key: "cinematic-lighting-correction",
label: "电影级光影校正",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"以选中图片为唯一参考,在保持角色身份、发型、服装、姿态和构图一致的前提下,进行电影级光影校正。增强自然逆光、轮廓光、皮肤通透感、背景层次和柔和景深,保留原始角色与场景,不改变人物数量,不生成宫格或分镜。输出单张 16:9 图片,不能出现文字、水印、编号或额外边框。",
description: "官方电影级光影校正:对参考图做单张电影感光影重塑。",
},
{
key: "frame-prediction-plus-3s",
label: "画面推演 - 3秒后",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"以选中图片作为当前画面,推演同一镜头时间线 3 秒后的画面。保持角色身份、服装、发型、场景和自然光线一致,只让动作、表情、衣袖/头发摆动、视线或镜头位置发生合理连续变化。输出单张 16:9 图片,不要生成宫格、分镜、文字、水印或编号。",
description: "官方画面推演:根据当前帧生成 3 秒后的单帧结果。",
},
{
key: "frame-prediction-minus-5s",
label: "画面推演 - 5秒前",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"以选中图片作为当前画面,反向推演同一镜头时间线 5 秒前的画面。保持角色身份、服装、发型、场景和自然光线一致,只让动作起势、姿态、表情、衣袖/头发位置或镜头位置回到合理的前一状态。输出单张 16:9 图片,不要生成宫格、分镜、文字、水印或编号。",
description: "官方画面推演:根据当前帧生成 5 秒前的单帧结果。",
},
{
key: "grid-split",
label: "宫格切分",
kind: "utility",
route: "local",
enabled: true,
prompt: "按当前图的标准宫格布局进行固定等分切分,将合成图切分为可单独调整的镜头图片。",
description: "对齐官方画布:4/9/16/25 和自定义宫格都按当前图的行列固定等分切分,并支持在当前图上多选宫格后继续创建生图或高清节点。",
},
{
key: "lighting",
label: "打光",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"在保持主体、构图和身份一致的前提下,对图片进行电影级光影重塑,增强轮廓光、层次、对比和空间感,保留自然材质细节。",
description: "图片编辑生图模型,复刻官方打光工具。",
},
{
key: "hd-upscale",
label: "高清",
kind: "utility",
route: "enhance",
enabled: true,
prompt: "提升图片清晰度、细节和分辨率,保持原图内容不变。",
description: "接入现有图片增强接口;高清重绘可在模型字段指定增强 Pro。",
},
{
key: "panorama-720",
label: "720全景",
kind: "image-edit",
route: "extend",
enabled: true,
prompt:
"基于当前图片向四周扩展为 720 全景视野,保持主体与场景一致,补全自然环境和空间透视,避免变形和重复纹理。",
description: "优先使用拓图接口,必要时可切到图片编辑生图模型。",
},
{
key: "director-panorama-ai",
label: "导演台 AI 全景图",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt:
"以输入图片为中心场景参考,生成可用于 3D 导演台的 360 度等距柱状全景背景。输出必须为 2:1 横向全景图,左右边缘无缝衔接,地平线水平,空间透视、光线、材质和色调与参考图一致。向画面外合理补全环境,不要复制主体、不要拉伸近景、不要文字、水印、边框或分格。",
description:
"导演台“AI生成”入口使用的后端模型与系统提示词,结果会自动创建画布图片节点并连接导演节点。",
},
{
key: "director-ai-import",
label: "导演台 AI 识图导入",
kind: "multimodal",
route: "director-ai-import",
enabled: true,
prompt:
"你是 3D 导演台场景规划器。分析输入图片中的人物、主要道具、地面关系、相对远近和镜头构图,输出严格 JSON。将画面中心映射到世界原点,画面横向映射到 X 轴,景深映射到 Z 轴;人物脚底落在 Y=0。只保留对站位和构图有帮助的主体,禁止臆造不可见物体。JSON 必须包含 summary、characters、props、cameras;characters 每项包含 name、bodyType、position、color;props 每项包含 name、geometryType、position、scale、color;cameras 每项包含 name、fov、position、target。",
description:
"导演台“AI 识图导入”远程模式使用的多模态模型和系统提示词,结果转换为可编辑站位参考层。",
},
{
key: "image-outpaint",
label: "扩图",
kind: "utility",
route: "extend",
enabled: true,
prompt: "在保持主体不变的前提下扩展画面边界,补全自然背景和构图。",
description: "对应官方高清菜单里的扩图。",
},
{
key: "image-repaint",
label: "重绘",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt: "保持主体结构和身份一致,按照用户补充提示重绘画面局部或整体风格。",
description: "对应官方高清菜单里的重绘。",
},
{
key: "image-erase",
label: "擦除",
kind: "image-edit",
route: "canvas-generate-image",
enabled: true,
prompt: "根据用户标记或文字说明移除指定元素,并自然补全背景纹理。",
description: "当前以前端提示词占位接图片编辑模型,后续可替换为蒙版编辑接口。",
},
{
key: "remove-bg",
label: "抠图",
kind: "utility",
route: "remove-bg",
enabled: true,
prompt: "移除图片背景,保留主体透明或干净背景结果。",
description: "接入现有抠图接口。",
},
{
key: "image-crop",
label: "裁剪",
kind: "utility",
route: "local",
enabled: true,
prompt: "按用户选择的裁剪比例或宫格切片范围处理图片。",
description: "本地画布裁剪/宫格切分能力。",
},
{
key: "angle-edit",
label: "多角度编辑器",
kind: "image-edit",
route: "angle-edit",
enabled: true,
prompt: "根据水平环绕、垂直俯仰和景别缩放参数生成新的角色/主体视角。",
description: "接现有 /api/angle-edit,多角度编辑器提交时触发。",
},
{
key: "story-script",
label: "故事脚本生成",
kind: "multimodal",
route: "canvas-workflow-analyze",
enabled: true,
prompt: "根据输入文本、图片或视频参考,生成结构化分镜脚本,包含镜号、场景、画面、声音和镜头节奏。",
description: "多模态大模型/文本模型生成脚本表格。",
},
{
key: "video-analysis",
label: "视频解析",
kind: "multimodal",
route: "canvas-workflow-analyze",
enabled: true,
cost: DEFAULT_VIDEO_ANALYSIS_COST,
prompt:
"解析当前视频的镜头节奏、景别变化、主体动作、背景音乐、人声/音效、关键帧要点,并输出可直接衔接后续图像生成与视频生成节点的结构化故事表。",
description: "多模态视频解析:抽取关键帧并输出视频故事表。",
},
{
key: "video-subtitle-erase",
label: "智能去字幕",
kind: "utility",
route: "local",
enabled: true,
cost: 0,
model: defaultVideoCanvasSubtitleEraseConfig.model,
prompt:
"仅检测并擦除视频画面中的中文或英文字幕。智能模式优先扫描画面下半区的高对比连续文字带;框选模式严格处理用户选中的全部区域,并保持原视频尺寸、时长和音轨。",
description: "本地逐帧字幕区域检测与擦除;检测阈值、范围和模糊半径可单独配置。",
},
{
key: "video-audio-separation",
label: "视频音频分离",
kind: "audio",
route: "local",
enabled: true,
cost: 0,
model: defaultVideoCanvasAudioSeparationConfig.model,
prompt:
"检测视频内嵌音轨并输出独立 WAV 与无声视频。人声模式提取左右声道中置信号,背景音模式提取左右声道差分信号;专业人声模型未配置时必须明确使用本地后备算法。",
description: "真实音视频拆轨及本地中置声道分离后备;仅对含内嵌音轨的视频启用。",
},
{
key: "image-to-prompt",
label: "图片反推提示词",
kind: "multimodal",
route: "canvas-workflow-analyze",
enabled: true,
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 或额外解释。",
generationPrompt: "重点遵循已标记元素:{{labels}}",
description: "视频节点“标记”使用的点选元素识别规则;结果以可切换的结构化标签写入提示词。",
requiresBackend: true,
},
{
key: "video-prompt-optimize",
label: "视频提示词优化",
kind: "utility",
route: "canvas-prompt-tools",
enabled: true,
model: "qwen3-vl-plus",
prompt:
"你是专业的视频生成提示词导演。请把用户输入扩写为可直接交给视频生成模型的中文提示词,只输出优化后的提示词正文,不要标题、Markdown、解释或引号。必须忠实保留用户原始主体、动作、场景、品牌名、@素材引用、数字和限制,不得擅自改变故事。先定义画面中的主体1、主体2及必要环境;再用一句话说明主体关系和要生成的核心动作;随后按镜头1、镜头2、镜头3的顺序给出至少3个连续镜头,覆盖景别、机位、运镜、动作、环境反馈和声音线索,简单描述可扩展到4个镜头;最后单独总结画风、主色调、光影、材质和氛围。若原输入已经很完整,则保留有效细节并补齐缺失的镜头连续性。段落之间使用换行,措辞具体、可拍摄、避免空泛形容词。",
description: "视频节点参数栏的提示词优化;结果先进入可重新生成和确认填充的独立面板。",
requiresBackend: true,
},
{
key: "video-prompt-translate",
label: "视频提示词翻译",
kind: "utility",
route: "canvas-prompt-tools",
enabled: true,
model: "qwen3-vl-plus",
prompt:
"你是视频生成提示词翻译器。自动判断输入的主要语言:中文输入翻译为自然、准确、适合视频模型理解的英文;英文输入翻译为流畅、准确的中文;混合语言则翻译到与主要语言相反的目标语言。只输出译文,不要标题、解释、Markdown或引号。必须完整保留 @素材引用、专有名词、模型参数、数字、镜头编号、标点语义和原有约束,不扩写、不删减、不优化内容。",
description: "视频节点参数栏的中英文自动识别双向翻译;译文直接替换当前输入。",
requiresBackend: true,
},
{
key: "text-to-video",
label: "文生视频",
kind: "video",
route: "canvas-generate-video",
enabled: true,
prompt: "根据文本描述生成短视频,保持镜头运动、节奏和电影级质感。",
description: "视频生成模型,走现有视频生成接口。",
},
{
key: "first-frame-video",
label: "首帧图生视频",
kind: "video",
route: "canvas-generate-video",
enabled: true,
prompt: "以参考图片作为视频首帧,生成自然流畅的短视频。",
description: "图片到视频/首帧生视频。",
},
{
key: "first-last-frame-video",
label: "首尾帧生成视频",
kind: "video",
route: "canvas-generate-video",
enabled: true,
prompt: "以两张参考图作为首帧和尾帧,生成首尾连贯的短视频。",
description: "首尾帧视频生成。",
},
{
key: "audio-to-video",
label: "音频生视频",
kind: "video",
route: "canvas-generate-video",
enabled: true,
prompt: "根据上传音频生成对应场景画面,镜头语言、节奏、音乐匹配情绪变化,电影级质感。",
description: "音频节点直接作为视频模型的音频参考输入;优先使用 Seedance 2.0,兼容 Wan 2.5。",
requiresBackend: true,
},
];
const effectAuthorAvatarUrls: Record<string, string> = {
参数迷宫: "https://liblibai-web-static.liblib.cloud/photo/16.png",
比特浮尘: "https://liblibai-web-static.liblib.cloud/photo/8.jpg",
代码极光: "https://liblibai-web-static.liblib.cloud/photo/10.jpg",
算法织梦者: "https://liblibai-web-static.liblib.cloud/photo/6.jpg",
赛博蒲公英: "https://liblibai-web-static.liblib.cloud/photo/12.jpg",
神经元旋涡: "https://liblibai-web-static.liblib.cloud/photo/11.jpg",
数据星云: "https://liblibai-web-static.liblib.cloud/photo/7.jpg",
数字游隼: "https://liblibai-web-static.liblib.cloud/photo/15.png",
算力潮汐: "https://liblibai-web-static.liblib.cloud/photo/13.jpg",
像素漫游者: "https://liblibai-web-static.liblib.cloud/photo/5.jpg",
量子呢喃: "https://liblibai-web-static.liblib.cloud/photo/2.jpg",
逻辑褶皱: "https://liblibai-web-static.liblib.cloud/photo/9.jpg",
模型幻景: "https://liblibai-web-static.liblib.cloud/photo/14.jpg",
矩阵回声: "https://liblibai-web-static.liblib.cloud/photo/3.jpg",
智能碎屑: "https://liblibai-web-static.liblib.cloud/photo/4.png",
认知碎片: "https://liblibai-web-static.liblib.cloud/photo/1.jpg",
};
const effectPromptOverrides: Record<string, string> = {
"bee-camera": "使用微型飞行器第一人称视角快速穿行场景,贴近主体掠过并保持连续、灵活的空间运动,形成小蜜蜂般轻盈迅捷的运镜。",
"cloud-dive": "镜头从高空云层快速向下俯冲,穿透云雾后自然揭示参考图主体和场景,保持方向连续并强化速度与纵深感。",
"earth-dive": "从太空视角向地球目标区域高速俯冲,依次穿过大气、云层和城市,最终自然抵达参考画面。",
"earth-zoom": "从参考场景连续快速拉远到城市、云层和地球尺度,保持地理方向和中心主体一致,形成无缝地球缩放转场。",
"global-zoom": "围绕地球尺度完成连续环绕和缩放,镜头从局部场景扩展至全球视野后平滑落回目标区域,运动轨迹清晰稳定。",
"horizon-flight": "镜头低空高速前进并在接近地平线时抬升飞越,空间尺度逐步展开,主体和环境运动保持稳定连贯。",
"iris-push": "镜头平滑推近人物眼睛并穿入瞳孔,以瞳孔纹理作为转场中心,保持面部身份稳定并避免眼部畸变。",
"multi-angle": "围绕主体在多个固定机位间平滑切换,主体位置和比例保持稳定,每个角度清晰展示不同侧面并避免身份漂移。",
"prada-change": "保持人物身份、姿态和镜头不变,以高级时装广告节奏完成连续换装,服装材质真实且每次变化衔接自然。",
"product-light-sweep": "在不改变产品造型、材质和品牌细节的前提下,让精致轮廓光从一侧平滑扫过产品表面,突出质感与高光细节。",
"reverse-gravity": "让场景和主体在保持结构一致的前提下出现引力反转,镜头随空间翻转并平滑过渡,避免突然跳切和形体崩坏。",
"underwater-slow": "将动作转为水下慢镜头质感,加入自然漂浮、细微气泡和柔和水体折射,保持主体外观清晰并让运动流畅连贯。",
};
type DefaultEffectSeed = readonly [
id: string,
title: string,
author: string,
usageCount: number,
thumbnail: string,
searchKeywords?: string,
];
const defaultEffectSeeds: DefaultEffectSeed[] = [
["bee-camera", "小蜜蜂运镜", "赛博蒲公英", 1100, "48.webp"],
["cloud-dive", "穿云而入", "数据星云", 574, "19.webp"],
["horizon-flight", "飞跃地平线", "赛博蒲公英", 1100, "36.webp"],
["reverse-gravity", "逆转引力", "量子呢喃", 248, "transition_2.webp"],
["earth-zoom", "地球缩放", "数字游隼", 197, "39.webp"],
["global-zoom", "环球缩放", "比特浮尘", 199, "40.webp"],
["iris-push", "瞳孔推镜", "参数迷宫", 445, "transition_6.webp"],
["earth-dive", "俯冲地球", "神经元旋涡", 281, "41.webp"],
["product-light-sweep", "产品扫光", "模型幻景", 1000, "17.webp", "产品扫光:100,商品广告,产品展示"],
["prada-change", "普拉达换装", "量子呢喃", 72, "35.webp", "产品扫光:40,时尚广告,连续换装"],
["multi-angle", "多角度定点", "代码极光", 562, "16.webp", "产品扫光:60,多角度产品展示"],
["underwater-slow", "水下慢镜头", "代码极光", 259, "transition_3.webp", "产品扫光:80,水下慢动作"],
["eye-makeup", "试妆特写", "像素漫游者", 102, "transition_5.webp", "美妆产品,面部特写"],
["float-in", "悬浮缓入", "像素漫游者", 179, "38.webp", "产品扫光:50,悬浮产品展示"],
["macro-push", "微距推镜", "智能碎屑", 287, "transition_4.webp", "产品扫光:90,商品微距特写"],
["helicopter-reveal", "直升机揭幕", "认知碎片", 102, "1.webp"],
["mountain-chase", "山路追击", "代码极光", 212, "44.webp"],
["snow-race", "雪地赛车", "智能碎屑", 108, "37.webp"],
["city-drive", "City Drive", "像素漫游者", 80, "21.webp", "城市驾驶,汽车广告"],
["deconstruct-3d", "3D解构", "算法织梦者", 326, "28.webp"],
["face-orbit", "面部环拍", "比特浮尘", 191, "transition_7.webp"],
["showroom", "Showroom", "智能碎屑", 68, "20.webp", "产品展厅,商品广告"],
["jewelry-closeup", "饰品特写", "逻辑褶皱", 68, "34.webp", "珠宝产品,商品特写"],
["giant-lookdown", "巨人俯瞰", "算法织梦者", 119, "43.webp"],
["runway", "Runway", "算力潮汐", 84, "transition_11.webp"],
["ai-dance", "AI 编舞", "比特浮尘", 525, "25.webp"],
["android", "机械姬", "认知碎片", 156, "29.webp"],
["star-scene", "巨星名场面", "数据星云", 86, "transition_9.webp"],
["mirror-double", "镜面分身", "赛博蒲公英", 56, "transition_8.webp"],
["iris-mutation", "瞳孔异变", "数字游隼", 54, "transition_12.webp"],
["red-carpet-flash", "红毯闪光灯", "认知碎片", 105, "transition_14.webp", "产品扫光:30,闪光灯,红毯"],
["cyber-makeup", "赛博化妆师", "算力潮汐", 28, "46.webp", "产品扫光:70,美妆产品,赛博妆容"],
["nebula-vortex", "星云漩涡", "神经元旋涡", 188, "transition_13.webp"],
["rain-control", "控雨术", "算力潮汐", 58, "24.webp"],
["burning-opening", "燃烧开场", "算法织梦者", 72, "transition_15.webp"],
["panoramic-camera", "全景相机", "参数迷宫", 45, "42.webp"],
["stardust-arrival", "星尘降临", "数据星云", 148, "45.webp"],
["turbo-camera", "涡轮运镜", "逻辑褶皱", 118, "transition_10.webp"],
["light-wings", "流光展翼", "赛博蒲公英", 45, "22.webp"],
["mecha-transform", "机甲变身", "矩阵回声", 133, "18.webp"],
["slow-explosion", "升格爆炸", "数字游隼", 134, "26.webp"],
["bullet-time", "子弹时间", "认知碎片", 271, "47.webp"],
["bee-camera-kling", "小蜜蜂运镜", "矩阵回声", 320, "49.webp", "产品扫光:20,穿梭机运镜,高速穿行"],
["villain-entry", "反派登场", "逻辑褶皱", 194, "23.webp"],
["dual-fight", "双人对打", "代码极光", 380, "30.webp"],
["slow-ko", "升格KO", "数据星云", 84, "31.webp"],
["dragon-rider", "驯龙高手", "模型幻景", 112, "33.webp", "产品扫光:10,巨兽飞行"],
["deep-sea-beast", "深海巨兽", "算力潮汐", 71, "32.webp"],
["bird-dissolve", "飞鸟解体", "神经元旋涡", 160, "27.webp"],
];
export const defaultVideoCanvasEffectPresets: VideoCanvasEffectPreset[] =
defaultEffectSeeds.map(
([id, title, author, usageCount, thumbnail, searchKeywords]) => ({
author,
authorAvatarUrl: effectAuthorAvatarUrls[author] || "",
commercial: true,
featured: true,
id,
model: "Seedance 2.0",
prompt:
effectPromptOverrides[id] ||
`保持参考主体和画面结构稳定,应用“${title}”的专业视频特效与镜头运动,过渡自然、动作连续、避免形体畸变和闪烁。`,
searchKeywords: searchKeywords || title,
thumbnailUrl: `https://libtv-res.liblib.art/tool/cinematic/${thumbnail}?x-oss-process=image/resize,m_fill,w_528,h_528/ignore-error,1`,
title,
usageCount,
}),
);
export const defaultVideoCanvasMotionPresets: VideoCanvasMotionPreset[] = [
{ id: "fixed", name: "固定镜头", previewUrl: "https://libtv-res.liblib.art/tool/movement/1.webp", prompt: "保持摄像机机位、焦距和构图稳定,不平移、不摇摄、不升降、不推拉,仅让画面主体自然运动。" },
{ id: "follow", name: "跟随拍摄", previewUrl: "https://libtv-res.liblib.art/tool/movement/2.webp", prompt: "摄像机平稳跟随主体移动,持续保持主体处于主要构图位置,运动方向和速度与主体一致。" },
{ id: "orbit-up", name: "盘旋抬升", previewUrl: "https://libtv-res.liblib.art/tool/movement/3.webp", prompt: "摄像机围绕主体平滑盘旋,同时逐渐抬升机位,形成由平视向高位俯视过渡的连续镜头。" },
{ id: "orbit-down", name: "盘旋下降", previewUrl: "https://libtv-res.liblib.art/tool/movement/4.webp", prompt: "摄像机围绕主体平滑盘旋,同时逐渐降低机位,形成由高位向低位视角过渡的连续镜头。" },
{ id: "tilt-up", name: "镜头上摇", previewUrl: "https://libtv-res.liblib.art/tool/movement/5.webp", prompt: "摄像机位置保持不变,镜头沿垂直方向由下向上平滑摇摄,逐步展示主体上方空间。" },
{ id: "tilt-down", name: "镜头下摇", previewUrl: "https://libtv-res.liblib.art/tool/movement/6.webp", prompt: "摄像机位置保持不变,镜头沿垂直方向由上向下平滑摇摄,逐步展示主体下方空间。" },
{ id: "pan-left", name: "镜头左摇", previewUrl: "https://libtv-res.liblib.art/tool/movement/7.webp", prompt: "摄像机位置保持不变,镜头从右向左水平平滑摇摄,连续揭示左侧画面内容。" },
{ id: "pan-right", name: "镜头右摇", previewUrl: "https://libtv-res.liblib.art/tool/movement/8.webp", prompt: "摄像机位置保持不变,镜头从左向右水平平滑摇摄,连续揭示右侧画面内容。" },
{ id: "rise", name: "镜头上升", previewUrl: "https://libtv-res.liblib.art/tool/movement/9.webp", prompt: "摄像机整体沿垂直方向平稳上升,朝向基本不变,逐渐获得更高机位和更开阔视野。" },
{ id: "descend", name: "镜头下降", previewUrl: "https://libtv-res.liblib.art/tool/movement/10.webp", prompt: "摄像机整体沿垂直方向平稳下降,朝向基本不变,逐渐接近主体所在高度。" },
{ id: "truck-left", name: "镜头左移", previewUrl: "https://libtv-res.liblib.art/tool/movement/11.webp", prompt: "摄像机整体向左平稳横移,焦距和朝向基本不变,产生清晰的横向视差。" },
{ id: "truck-right", name: "镜头右移", previewUrl: "https://libtv-res.liblib.art/tool/movement/12.webp", prompt: "摄像机整体向右平稳横移,焦距和朝向基本不变,产生清晰的横向视差。" },
{ id: "push-in", name: "镜头前推", previewUrl: "https://libtv-res.liblib.art/tool/movement/13.webp", prompt: "摄像机沿光轴平稳向主体推进,真实改变空间透视并逐渐放大主体,保持焦点稳定。" },
{ id: "pull-out", name: "镜头后移", previewUrl: "https://libtv-res.liblib.art/tool/movement/14.webp", prompt: "摄像机沿光轴平稳远离主体,真实改变空间透视并逐渐展示更完整的环境。" },
{ id: "zoom-in", name: "变焦推进", previewUrl: "https://libtv-res.liblib.art/tool/movement/15.webp", prompt: "摄像机位置保持不变,平滑拉长焦距完成变焦推进,让主体在画面中逐渐放大。" },
{ id: "zoom-out", name: "变焦拉远", previewUrl: "https://libtv-res.liblib.art/tool/movement/16.webp", prompt: "摄像机位置保持不变,平滑缩短焦距完成变焦拉远,让视野逐渐扩大并展示环境。" },
{ id: "dolly-zoom", name: "柯克变焦", previewUrl: "https://libtv-res.liblib.art/tool/movement/17.webp", prompt: "执行平滑的希区柯克变焦:摄像机推拉与反向变焦同步,主体尺寸近似稳定,背景透视明显压缩或扩张。" },
{ id: "orbit", name: "环绕拍摄", previewUrl: "https://libtv-res.liblib.art/tool/movement/18.webp", prompt: "摄像机以主体为中心进行平滑弧形环绕,持续朝向主体并产生明显、连贯的空间视差。" },
{ id: "roll", name: "滚筒旋转", previewUrl: "https://libtv-res.liblib.art/tool/movement/19.webp", prompt: "摄像机沿镜头光轴平滑滚转,画面地平线连续旋转,主体运动与空间关系保持连贯。" },
{ id: "first-person", name: "第一视角", previewUrl: "https://libtv-res.liblib.art/tool/movement/20.webp", prompt: "采用角色第一人称主观视角,摄像机运动贴合角色头部和行进节奏,呈现自然的临场感。" },
{ id: "drone", name: "无人机", previewUrl: "https://libtv-res.liblib.art/tool/movement/21.webp", prompt: "采用稳定的无人机飞行镜头,摄像机在三维空间中平滑穿行,保持开阔视野和连续航线。" },
{ id: "aerial", name: "高空航拍", previewUrl: "https://libtv-res.liblib.art/tool/movement/22.webp", prompt: "采用高空航拍视角,摄像机从高位俯瞰场景并稳定滑行,突出环境规模、地形与空间关系。" },
{ id: "handheld", name: "手持拍摄", previewUrl: "https://libtv-res.liblib.art/tool/movement/23.webp", prompt: "模拟真实手持摄影的轻微呼吸感和自然晃动,运动克制且连贯,避免机械稳定或剧烈抖动。" },
];
const LIBLIB_ROLE_ASSET_BASE_URL = "https://libtv-res.liblib.art/tool/character";
type DefaultRolePresetInput = {
age: string;
culture?: string;
era: string;
gender: string;
id: string;
imageNumber: string;
name: string;
role: string;
};
function createDefaultRolePreset(input: DefaultRolePresetInput): VideoCanvasRolePreset {
const imageBase = `${LIBLIB_ROLE_ASSET_BASE_URL}/${input.imageNumber}`;
const imageVariant = input.imageNumber === "003" ? "_1" : "";
return {
id: input.id,
assetId: `public-role:${input.imageNumber}`,
name: input.name,
accent: "#252525",
description: `${input.gender} · ${input.age} · ${input.era} · ${input.role}`,
prompt: `保持角色「${input.name}」在所有镜头中的五官、发型、服装、体型和身份特征与所选角色素材严格一致;仅允许剧情要求的动作、表情、机位和光线变化,避免换脸、换装、年龄漂移、性别漂移和肢体畸变。`,
tags: `${input.gender} · ${input.age} · ${input.era} · ${input.role}`,
compliant: true,
images: {
expression: `${imageBase}_expression_grid${imageVariant}.png`,
face: `${imageBase}_face_closeup${imageVariant}.png`,
full: `${imageBase}_full_body${imageVariant}.png`,
threeView: `${imageBase}_character_sheet${imageVariant}.png`,
},
filters: {
age: input.age,
build: "匀称",
culture: input.culture || "东亚",
era: input.era,
gender: input.gender,
hairColor: "",
species: "人类",
},
};
}
export const defaultVideoCanvasRolePresets: VideoCanvasRolePreset[] = [
createDefaultRolePreset({ id: "role-003", imageNumber: "003", name: "甜妹/清新少女", gender: "女", age: "青年", era: "现代", role: "女主" }),
createDefaultRolePreset({ id: "role-001", imageNumber: "001", name: "霸总/精英大佬", gender: "男", age: "青年", era: "现代", role: "男主" }),
createDefaultRolePreset({ id: "role-002", imageNumber: "002", name: "温柔熟男/理想男友", gender: "男", age: "青年", era: "现代", role: "男主" }),
createDefaultRolePreset({ id: "role-004", imageNumber: "004", name: "清冷千金/白切黑女主", gender: "女", age: "青年", era: "现代", role: "女主" }),
createDefaultRolePreset({ id: "role-005", imageNumber: "005", name: "古风男主", gender: "男", age: "青年", era: "古代", role: "男主" }),
createDefaultRolePreset({ id: "role-006", imageNumber: "006", name: "古风女主", gender: "女", age: "青年", era: "古代", role: "女主" }),
createDefaultRolePreset({ id: "role-007", imageNumber: "007", name: "恶毒女配/白莲花", gender: "女", age: "青年", era: "现代", role: "配角" }),
createDefaultRolePreset({ id: "role-010", imageNumber: "010", name: "正派长辈/父", gender: "男", age: "中年", era: "现代", role: "配角" }),
createDefaultRolePreset({ id: "role-011", imageNumber: "011", name: "正派长辈/母", gender: "女", age: "中年", era: "现代", role: "配角" }),
createDefaultRolePreset({ id: "role-012", imageNumber: "012", name: "反派长辈/势利亲戚", gender: "男", age: "中年", era: "现代", role: "配角" }),
createDefaultRolePreset({ id: "role-013", imageNumber: "013", name: "反派长辈/势利亲戚", gender: "女", age: "中年", era: "现代", role: "配角" }),
createDefaultRolePreset({ id: "role-014", imageNumber: "014", name: "生活方式普通人", gender: "男", age: "青年", era: "现代", role: "人物" }),
createDefaultRolePreset({ id: "role-015", imageNumber: "015", name: "生活方式普通人", gender: "女", age: "青年", era: "现代", role: "人物" }),
createDefaultRolePreset({ id: "role-016", imageNumber: "016", name: "时尚感亚洲男生", gender: "男", age: "青年", era: "现代", role: "时尚感角色" }),
createDefaultRolePreset({ id: "role-018", imageNumber: "018", name: "时尚感亚洲女生", gender: "女", age: "青年", era: "现代", role: "时尚感角色" }),
createDefaultRolePreset({ id: "role-022", imageNumber: "022", name: "时尚感欧美男生", gender: "男", age: "青年", era: "现代", role: "时尚感角色", culture: "欧美" }),
createDefaultRolePreset({ id: "role-021", imageNumber: "021", name: "时尚感欧美女生", gender: "女", age: "青年", era: "现代", role: "时尚感角色", culture: "欧美" }),
createDefaultRolePreset({ id: "role-008", imageNumber: "008", name: "小男孩", gender: "男", age: "少年", era: "现代", role: "儿童" }),
createDefaultRolePreset({ id: "role-009", imageNumber: "009", name: "小女孩", gender: "女", age: "少年", era: "现代", role: "儿童" }),
createDefaultRolePreset({ id: "role-019", imageNumber: "019", name: "时尚感亚洲女生", gender: "女", age: "青年", era: "现代", role: "时尚感角色" }),
createDefaultRolePreset({ id: "role-020", imageNumber: "020", name: "时尚感亚洲女生", gender: "女", age: "青年", era: "现代", role: "时尚感角色" }),
createDefaultRolePreset({ id: "role-017", imageNumber: "017", name: "时尚感亚洲男生", gender: "男", age: "青年", era: "现代", role: "时尚感角色" }),
createDefaultRolePreset({ id: "role-023", imageNumber: "023", name: "时尚感欧美女生", gender: "女", age: "青年", era: "现代", role: "时尚感角色", culture: "欧美" }),
];
export const defaultVideoCanvasSubjectLibraryConfig: VideoCanvasSubjectLibraryConfig = {
enabled: true,
historyLibraryEnabled: true,
imageAccept: "image/jpeg,image/png,image/webp",
imageMinCount: 2,
imageMaxCount: 4,
imageMaxFileSizeMb: 50,
videoAccept: "video/mp4,video/quicktime",
videoMinDurationSeconds: 3,
videoMaxDurationSeconds: 8,
videoMaxFileSizeMb: 50,
nameMaxLength: 20,
descriptionMaxLength: 100,
requireClearFrontFace: true,
unselectedCategory: "其他",
voiceHelpText: "仅主体为人物时支持音色绑定和使用",
labels: ["无标签", "人物", "场景", "道具", "特效", "其他"],
voices: [
{ id: "none", name: "无配音", badge: "", previewUrl: "" },
{ id: "fishing-girlfriend", name: "钓系女友", badge: "官方", previewUrl: "" },
{ id: "gentle-female", name: "温柔女声", badge: "官方", previewUrl: "" },
{ id: "broadcast-male", name: "播报男声", badge: "官方", previewUrl: "" },
{ id: "salty-teen", name: "盐系少年", badge: "官方", previewUrl: "" },
{ id: "coquettish-girlfriend", name: "撒娇女友", badge: "官方", previewUrl: "" },
{ id: "original-video", name: "视频原声", badge: "官方", previewUrl: "" },
],
smartDescription: {
cost: 1,
enabled: true,
maxTokens: 240,
prompt:
"你是主体素材画面描述助手。综合查看用户提交的全部图片或视频关键帧,用简体中文客观描述画面中可见的主体数量、类型、性别外观、发型、服饰、颜色、标识和展示视角。不要假设不同画面一定是同一个主体;如果出现多个明显不同的人物或物体,要分别描述。不得虚构身份、姓名、剧情、性格或看不见的细节,不得评价美丑,不要输出标题、序号、引号、Markdown、解释或提示语,只输出一段不超过100个汉字的描述。",
temperature: 0.2,
},
smartCompletion: {
aspectRatio: "3:4",
candidateCount: 9,
cost: 6,
enabled: true,
imageSize: "1K",
maxSelected: 3,
model: "auto",
prompt:
"严格保持参考图中人物的身份、脸型、五官、发型、发色、服装、配饰、体型和整体画风完全一致。生成一张干净白底的单人物主体参考图;候选结果需要覆盖正面近景、正面半身、正面全身、左侧面、右侧面、左后侧、右后侧、背面半身和背面全身等不同机位。每张只展示一个明确视角,人物完整清晰,不添加文字、边框、拼图、额外人物、道具或场景。",
},
videoUsage: {
enabled: true,
maxSubjects: 4,
modelPatterns: ["Kling O3"],
promptTemplate:
"主体{{index}}「{{name}}」:{{description}}。在所有镜头中严格保持该主体的身份、五官、发型、服装、体型、颜色、材质和标识与主体参考素材一致,只允许提示词要求的动作、表情、机位、光线和环境变化。",
},
};
export const defaultVideoCanvasSmartStoryboardConfig: VideoCanvasSmartStoryboardConfig = {
defaultShotDurationSeconds: 2,
enabled: true,
maxDescriptionLength: 2000,
maxShots: 15,
maxTotalDurationSeconds: 15,
minShotDurationSeconds: 1,
minTotalDurationSeconds: 3,
modelPatterns: ["Kling O3"],
promptTemplate:
"按以下智能分镜生成一条总时长严格为{{totalDuration}}秒的视频,共{{shotCount}}个连续镜头。必须按顺序执行,每个镜头持续指定秒数,镜头之间自然衔接,不得调换、合并、遗漏或额外增加镜头。\n\n全局画面与一致性要求:\n{{prompt}}\n\n分镜时序:\n{{shots}}",
shotPromptTemplate: "[分镜{{index}}|{{duration}}秒] {{description}}",
};
export const defaultVideoCanvasFunctionConfig: VideoCanvasFunctionConfig = {
enabled: true,
defaultImageModel: "auto",
defaultVideoModel: "auto",
defaultMultimodalModel: "auto",
directorDesk: {
aiImport: {
cost: 48,
enabled: true,
functionKey: "director-ai-import",
historyLimit: 24,
localSamEndpoint: "",
uploadAccept: "image/*",
},
cameraPresets: defaultVideoCanvasDirectorCameraPresets,
defaultAspectRatio: "9:16",
enabled: true,
embedUrl: "/vendor/storyai-director/index.html?theme=dark",
gaussian: {
maxFileSizeMb: 200,
minRaycastOpacity: 0.1,
uploadAccept: ".ply,.splat,.spz,.ksplat,application/octet-stream",
},
crowd: {
defaultColumns: 3,
defaultRows: 3,
defaultSpacing: 1.2,
},
helpUrl: "https://resonate.feishu.cn/wiki/Loxfw6XHziYRk0kKzdjcFfp9nhb",
panorama: {
aiAspectRatio: "2:1",
aiCost: 22,
aiFunctionKey: "director-panorama-ai",
aiImageSize: "2K",
aiPreprocessDelayMs: 3000,
historyLimit: 24,
uploadAccept: ".png",
},
posePresets: defaultVideoCanvasDirectorPosePresets,
showBatchCaptureControls: false,
},
effectPresets: defaultVideoCanvasEffectPresets,
historyAssets: {
defaultZoomPercent: 100,
enabled: true,
maxItems: 300,
picker: defaultVideoCanvasHistoryPickerConfig,
previewAutoplay: true,
showWatermarkBadge: true,
watermarkBadgeText: "AI",
},
motionPresets: defaultVideoCanvasMotionPresets,
nodeCreation: defaultVideoCanvasNodeCreationConfig,
rolePresets: defaultVideoCanvasRolePresets,
smartStoryboard: defaultVideoCanvasSmartStoryboardConfig,
subjectLibrary: defaultVideoCanvasSubjectLibraryConfig,
toolbox: defaultVideoCanvasToolboxConfig,
tutorialSupport: defaultVideoCanvasTutorialSupportConfig,
videoAdvancedSettings: {
defaultAutoValidateMaterial: true,
defaultSearchEnabled: true,
},
videoGenerationParameters: defaultVideoCanvasGenerationParametersConfig,
videoModelCatalog: defaultVideoCanvasModelCatalog,
videoReferenceSelection: {
defaultRule: {
autoExit: false,
hideActionAtLimit: false,
maxImages: 9,
mode: "reference-images",
},
modelRules: [
{
autoExit: true,
hideActionAtLimit: true,
maxImages: 1,
mode: "first-frame",
modelPattern: "Seedance1.5 Pro",
},
],
},
videoAudioSeparation: defaultVideoCanvasAudioSeparationConfig,
videoDownload: defaultVideoCanvasVideoDownloadConfig,
videoHd: defaultVideoCanvasVideoHdConfig,
videoSubtitleErase: defaultVideoCanvasSubtitleEraseConfig,
functions: Object.fromEntries(defaultFunctions.map((item) => [item.key, item])),
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeDirectorDeskHelpUrl(value: unknown, fallback: string) {
const candidate = String(value || "").trim().slice(0, 2048);
if (candidate.startsWith("/") && !candidate.startsWith("//")) return candidate;
try {
const url = new URL(candidate);
return url.protocol === "http:" || url.protocol === "https:" ? candidate : fallback;
} catch {
return fallback;
}
}
function normalizeDirectorCameraVector(
value: unknown,
fallback: [number, number, number],
): [number, number, number] {
if (!Array.isArray(value) || value.length !== 3) return fallback;
const normalized = value.map((item) => {
const number = Number(item);
return Number.isFinite(number)
? Number(Math.max(-1000, Math.min(1000, number)).toFixed(4))
: Number.NaN;
});
return normalized.every(Number.isFinite)
? normalized as [number, number, number]
: fallback;
}
function normalizeDirectorCameraPresets(value: unknown): VideoCanvasDirectorCameraPreset[] {
if (!Array.isArray(value)) return defaultVideoCanvasDirectorCameraPresets;
const seenIds = new Set<string>();
const presets = value.slice(0, 32).flatMap((item, index) => {
if (!isRecord(item)) return [];
const fallback = defaultVideoCanvasDirectorCameraPresets[index] || {
fov: 50,
position: [0, 2.2, 10] as [number, number, number],
target: [0, 1.2, 0] as [number, number, number],
};
const id = String(item.id || "").trim().slice(0, 80);
const label = String(item.label || "").trim().slice(0, 80);
if (!id || !label || seenIds.has(id)) return [];
const parsedFov = Number(item.fov);
const fov = Number.isFinite(parsedFov)
? Number(Math.max(1, Math.min(179, parsedFov)).toFixed(2))
: fallback.fov;
seenIds.add(id);
return [{
fov,
id,
label,
position: normalizeDirectorCameraVector(item.position, fallback.position),
source: item.source === "current" ? "current" as const : "fixed" as const,
target: normalizeDirectorCameraVector(item.target, fallback.target),
}];
});
return presets.length > 0 ? presets : defaultVideoCanvasDirectorCameraPresets;
}
function normalizeDirectorPosePresets(value: unknown): VideoCanvasDirectorPosePreset[] {
if (!Array.isArray(value)) return defaultVideoCanvasDirectorPosePresets;
const seenIds = new Set<string>();
const presets = value.slice(0, 50).flatMap((item) => {
if (!isRecord(item) || !isRecord(item.controls)) return [];
const id = String(item.id || "").trim().slice(0, 80);
const label = String(item.label || "").trim().slice(0, 80);
if (!id || !label || seenIds.has(id)) return [];
const controls = Object.fromEntries(
Object.entries(item.controls)
.slice(0, 100)
.flatMap(([key, rawValue]) => {
const value = Number(rawValue);
if (!key.trim() || !Number.isFinite(value)) return [];
return [[key.trim().slice(0, 100), Math.max(-360, Math.min(360, value))]];
}),
);
seenIds.add(id);
return [{ controls, id, label }];
});
return presets.length > 0 ? presets : defaultVideoCanvasDirectorPosePresets;
}
function normalizeClampedInteger(
value: unknown,
fallback: number,
min: number,
max: number,
) {
const parsed = Number(value);
return Number.isFinite(parsed)
? Math.max(min, Math.min(max, Math.round(parsed)))
: fallback;
}
const legacyFunctionPrompts: Record<string, string[]> = {
"story-beat-grid-4": [
"以选中图片作为角色身份、服装、发型、场景光线、镜头距离和画面质感的唯一参考,生成一张 2x2 剧情推演四宫格合成图。四格都必须保持参考图的近景/半身近中景人物构图,主体占画面主要面积,不要远景、全景、森林空镜或主体很小的画面。四格是同一角色在同一自然光场景中的连续轻微剧情变化:1 正面安静凝视,2 保留参考图抬手/袖摆动作的延续,3 视线或表情轻微变化,4 情绪收束的近中景反应。保持五官、发型、绿色服装、柔和森林逆光、色调和景深一致;每格角度和动作有轻微差异,但不能像 9 个不同机位分镜。输出单张 16:9 图片,2x2 等分排版,分割线清晰干净,不能出现文字、水印、编号、边框标签或 3x3/5x5 宫格。",
"以选中图片作为角色、服装、发型、场景光线的唯一参考,生成一张 3x3 多机位分镜合成图。必须是 9 个明显不同的镜头画面,不是同一张图裁切或重复人脸。九格分别包含:1 森林环境远景/主体很小,2 全身远景,3 中景正面,4 侧面半身,5 低机位或俯拍,6 面部近景,7 手部/衣袖/动作细节,8 背面或侧背,9 仰视或情绪特写。保持同一角色身份、绿色服装、自然森林光线一致,每格构图、景别、机位和主体大小必须不同。输出为单张 16:9 图片,九格之间使用清晰黑色分割线,不能出现文字、水印、编号,不能白底棚拍。",
],
"continuous-storyboard-grid-25": [
"以选中图片作为角色、服装、发型、场景光线和电影质感的唯一参考,生成一张 5x5 连贯分镜合成图。必须是 25 个连续镜头,体现从远景建立场景、人物动作推进、情绪变化、细节特写到结尾反应的完整微型叙事,不是同一张图重复裁切。保持同一角色身份和绿色服装一致,森林自然光线统一;每格景别、机位、主体大小或动作都要有明显差异。输出为单张 16:9 图片,5x5 等分排版,使用清晰分割线,不能出现文字、水印、编号或多余边框说明。",
],
"character-three-view": [
"以输入角色图为唯一角色参考,生成正面、侧面、背面三视图角色设定图。保持五官、发型、服装、颜色和体型一致,干净白底,三视图水平排布,适合后续视频角色一致性使用。",
],
};
const legacyFunctionLabels: Record<string, string[]> = {
"character-three-view": ["角色三视图"],
};
function normalizeComparableText(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function normalizeFunctionLabel(
key: string,
value: unknown,
fallbackLabel: string,
) {
const label = typeof value === "string" ? value.trim() : "";
if (!label) return fallbackLabel;
const normalizedLabel = normalizeComparableText(label);
const isLegacyLabel = (legacyFunctionLabels[key] || []).some(
(legacyLabel) => normalizeComparableText(legacyLabel) === normalizedLabel,
);
return isLegacyLabel ? fallbackLabel : label;
}
function normalizeFunctionPrompt(
key: string,
value: unknown,
fallbackPrompt: string,
) {
const prompt = typeof value === "string" ? value.trim() : "";
if (!prompt) return fallbackPrompt;
const normalizedPrompt = normalizeComparableText(prompt);
const isStoryPromptClearlyNineGrid =
key === "story-beat-grid-4" &&
/(?:生成一张\s*3\s*[x×]\s*3|生成一张[^。]*九宫格|九格分别|必须是\s*9\s*个)/.test(
normalizedPrompt,
);
if (isStoryPromptClearlyNineGrid) return fallbackPrompt;
const isLegacyPrompt = (legacyFunctionPrompts[key] || []).some(
(legacyPrompt) => normalizeComparableText(legacyPrompt) === normalizedPrompt,
);
return isLegacyPrompt ? fallbackPrompt : prompt;
}
function normalizeFunctionDefinition(
key: string,
value: unknown,
fallback: VideoCanvasFunctionDefinition,
): VideoCanvasFunctionDefinition {
const incoming = isRecord(value) ? value : {};
const normalized: VideoCanvasFunctionDefinition = {
...fallback,
key,
label: normalizeFunctionLabel(key, incoming.label, fallback.label),
kind: (incoming.kind as VideoCanvasFunctionKind) || fallback.kind,
route: (incoming.route as VideoCanvasBackendRoute) || fallback.route,
enabled: typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled,
cost:
incoming.cost !== undefined || fallback.cost !== undefined
? normalizeClampedInteger(incoming.cost, fallback.cost || 0, 0, 9999)
: undefined,
model: typeof incoming.model === "string" ? incoming.model : fallback.model,
prompt: normalizeFunctionPrompt(key, incoming.prompt, fallback.prompt),
generationPrompt:
typeof incoming.generationPrompt === "string"
? incoming.generationPrompt.slice(0, 10_000)
: fallback.generationPrompt,
description: String(incoming.description || fallback.description),
requiresBackend:
typeof incoming.requiresBackend === "boolean"
? incoming.requiresBackend
: fallback.requiresBackend,
};
return normalized;
}
function normalizeVideoReferenceBehavior(
value: unknown,
fallback: VideoCanvasReferenceBehavior,
): VideoCanvasReferenceBehavior {
const incoming = isRecord(value) ? value : {};
const mode =
incoming.mode === "first-frame" || incoming.mode === "reference-images"
? incoming.mode
: fallback.mode;
return {
autoExit:
typeof incoming.autoExit === "boolean" ? incoming.autoExit : fallback.autoExit,
hideActionAtLimit:
typeof incoming.hideActionAtLimit === "boolean"
? incoming.hideActionAtLimit
: fallback.hideActionAtLimit,
maxImages: normalizeClampedInteger(incoming.maxImages, fallback.maxImages, 1, 20),
mode,
};
}
function normalizeVideoReferenceSelectionConfig(
value: unknown,
): VideoCanvasReferenceSelectionConfig {
const fallback = defaultVideoCanvasFunctionConfig.videoReferenceSelection;
const incoming = isRecord(value) ? value : {};
const defaultRule = normalizeVideoReferenceBehavior(
incoming.defaultRule,
fallback.defaultRule,
);
const modelRules = Array.isArray(incoming.modelRules)
? incoming.modelRules.flatMap((item, index) => {
const normalizedItem = isRecord(item) ? item : {};
const modelPattern = String(normalizedItem.modelPattern || "")
.trim()
.slice(0, 120);
if (!modelPattern) return [];
const fallbackRule = fallback.modelRules[index] || {
...defaultRule,
modelPattern,
};
return [
{
...normalizeVideoReferenceBehavior(normalizedItem, fallbackRule),
modelPattern,
},
];
})
: fallback.modelRules;
return { defaultRule, modelRules };
}
function normalizeVideoCanvasSubjectLibraryConfig(
value: unknown,
): VideoCanvasSubjectLibraryConfig {
const fallback = defaultVideoCanvasSubjectLibraryConfig;
const incoming = isRecord(value) ? value : {};
const incomingSmartDescription = isRecord(incoming.smartDescription)
? incoming.smartDescription
: {};
const incomingSmartCompletion = isRecord(incoming.smartCompletion)
? incoming.smartCompletion
: {};
const incomingVideoUsage = isRecord(incoming.videoUsage)
? incoming.videoUsage
: {};
const imageMinCount = normalizeClampedInteger(
incoming.imageMinCount,
fallback.imageMinCount,
1,
10,
);
const imageMaxCount = normalizeClampedInteger(
incoming.imageMaxCount,
fallback.imageMaxCount,
imageMinCount,
10,
);
const videoMinDurationSeconds = normalizeClampedInteger(
incoming.videoMinDurationSeconds,
fallback.videoMinDurationSeconds,
1,
60,
);
const videoMaxDurationSeconds = normalizeClampedInteger(
incoming.videoMaxDurationSeconds,
fallback.videoMaxDurationSeconds,
videoMinDurationSeconds,
60,
);
const labels = Array.isArray(incoming.labels)
? Array.from(
new Set(
incoming.labels
.map((item) => String(item || "").trim().slice(0, 20))
.filter(Boolean),
),
).slice(0, 12)
: fallback.labels;
const voices = Array.isArray(incoming.voices)
? incoming.voices
.slice(0, 50)
.flatMap((item) => {
const voice = isRecord(item) ? item : {};
const id = String(voice.id || "").trim().slice(0, 80);
const name = String(voice.name || "").trim().slice(0, 80);
if (!id || !name) return [];
return [
{
id,
name,
badge: String(voice.badge || "").trim().slice(0, 30),
previewUrl: String(voice.previewUrl || "").trim().slice(0, 2048),
} satisfies VideoCanvasSubjectVoicePreset,
];
})
: fallback.voices;
const parsedTemperature = Number(incomingSmartDescription.temperature);
return {
enabled:
typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled,
historyLibraryEnabled:
typeof incoming.historyLibraryEnabled === "boolean"
? incoming.historyLibraryEnabled
: fallback.historyLibraryEnabled,
imageAccept:
String(incoming.imageAccept || "").trim().slice(0, 200) ||
fallback.imageAccept,
imageMinCount,
imageMaxCount,
imageMaxFileSizeMb: normalizeClampedInteger(
incoming.imageMaxFileSizeMb,
fallback.imageMaxFileSizeMb,
1,
500,
),
videoAccept:
String(incoming.videoAccept || "").trim().slice(0, 200) ||
fallback.videoAccept,
videoMinDurationSeconds,
videoMaxDurationSeconds,
videoMaxFileSizeMb: normalizeClampedInteger(
incoming.videoMaxFileSizeMb,
fallback.videoMaxFileSizeMb,
1,
500,
),
nameMaxLength: normalizeClampedInteger(
incoming.nameMaxLength,
fallback.nameMaxLength,
1,
100,
),
descriptionMaxLength: normalizeClampedInteger(
incoming.descriptionMaxLength,
fallback.descriptionMaxLength,
10,
1000,
),
requireClearFrontFace:
typeof incoming.requireClearFrontFace === "boolean"
? incoming.requireClearFrontFace
: fallback.requireClearFrontFace,
unselectedCategory:
String(incoming.unselectedCategory || "").trim().slice(0, 20) ||
fallback.unselectedCategory,
voiceHelpText:
String(incoming.voiceHelpText || "").trim().slice(0, 240) ||
fallback.voiceHelpText,
labels: labels.length > 0 ? labels : fallback.labels,
voices: voices.length > 0 ? voices : fallback.voices,
smartDescription: {
cost: normalizeClampedInteger(
incomingSmartDescription.cost,
fallback.smartDescription.cost,
0,
100_000,
),
enabled:
typeof incomingSmartDescription.enabled === "boolean"
? incomingSmartDescription.enabled
: fallback.smartDescription.enabled,
maxTokens: normalizeClampedInteger(
incomingSmartDescription.maxTokens,
fallback.smartDescription.maxTokens,
32,
1000,
),
prompt:
String(incomingSmartDescription.prompt || "").trim().slice(0, 20_000) ||
fallback.smartDescription.prompt,
temperature: Number.isFinite(parsedTemperature)
? Math.max(0, Math.min(1, Number(parsedTemperature.toFixed(2))))
: fallback.smartDescription.temperature,
},
smartCompletion: {
aspectRatio:
String(incomingSmartCompletion.aspectRatio || "").trim().slice(0, 30) ||
fallback.smartCompletion.aspectRatio,
candidateCount: normalizeClampedInteger(
incomingSmartCompletion.candidateCount,
fallback.smartCompletion.candidateCount,
1,
12,
),
cost: normalizeClampedInteger(
incomingSmartCompletion.cost,
fallback.smartCompletion.cost,
0,
100_000,
),
enabled:
typeof incomingSmartCompletion.enabled === "boolean"
? incomingSmartCompletion.enabled
: fallback.smartCompletion.enabled,
imageSize:
String(incomingSmartCompletion.imageSize || "").trim().slice(0, 30) ||
fallback.smartCompletion.imageSize,
maxSelected: normalizeClampedInteger(
incomingSmartCompletion.maxSelected,
fallback.smartCompletion.maxSelected,
1,
Math.max(1, imageMaxCount - 1),
),
model:
String(incomingSmartCompletion.model || "").trim().slice(0, 160) ||
fallback.smartCompletion.model,
prompt:
String(incomingSmartCompletion.prompt || "").trim().slice(0, 20_000) ||
fallback.smartCompletion.prompt,
},
videoUsage: {
enabled:
typeof incomingVideoUsage.enabled === "boolean"
? incomingVideoUsage.enabled
: fallback.videoUsage.enabled,
maxSubjects: normalizeClampedInteger(
incomingVideoUsage.maxSubjects,
fallback.videoUsage.maxSubjects,
1,
12,
),
modelPatterns: Array.isArray(incomingVideoUsage.modelPatterns)
? Array.from(
new Set(
incomingVideoUsage.modelPatterns
.map((item) => String(item || "").trim().slice(0, 120))
.filter(Boolean),
),
).slice(0, 24)
: fallback.videoUsage.modelPatterns,
promptTemplate:
String(incomingVideoUsage.promptTemplate || "").trim().slice(0, 20_000) ||
fallback.videoUsage.promptTemplate,
},
};
}
function normalizeVideoCanvasSmartStoryboardConfig(
value: unknown,
): VideoCanvasSmartStoryboardConfig {
const fallback = defaultVideoCanvasSmartStoryboardConfig;
const incoming = isRecord(value) ? value : {};
const minTotalDurationSeconds = normalizeClampedInteger(
incoming.minTotalDurationSeconds,
fallback.minTotalDurationSeconds,
1,
60,
);
const maxTotalDurationSeconds = normalizeClampedInteger(
incoming.maxTotalDurationSeconds,
fallback.maxTotalDurationSeconds,
minTotalDurationSeconds,
180,
);
const minShotDurationSeconds = normalizeClampedInteger(
incoming.minShotDurationSeconds,
fallback.minShotDurationSeconds,
1,
maxTotalDurationSeconds,
);
return {
defaultShotDurationSeconds: normalizeClampedInteger(
incoming.defaultShotDurationSeconds,
fallback.defaultShotDurationSeconds,
minShotDurationSeconds,
maxTotalDurationSeconds,
),
enabled:
typeof incoming.enabled === "boolean" ? incoming.enabled : fallback.enabled,
maxDescriptionLength: normalizeClampedInteger(
incoming.maxDescriptionLength,
fallback.maxDescriptionLength,
100,
20_000,
),
maxShots: normalizeClampedInteger(
incoming.maxShots,
fallback.maxShots,
1,
100,
),
maxTotalDurationSeconds,
minShotDurationSeconds,
minTotalDurationSeconds,
modelPatterns: Array.isArray(incoming.modelPatterns)
? Array.from(
new Set(
incoming.modelPatterns
.map((item) => String(item || "").trim().slice(0, 120))
.filter(Boolean),
),
).slice(0, 24)
: fallback.modelPatterns,
promptTemplate:
String(incoming.promptTemplate || "").trim().slice(0, 20_000) ||
fallback.promptTemplate,
shotPromptTemplate:
String(incoming.shotPromptTemplate || "").trim().slice(0, 5000) ||
fallback.shotPromptTemplate,
};
}
export function normalizeVideoCanvasFunctionConfig(value: unknown): VideoCanvasFunctionConfig {
if (!isRecord(value)) return defaultVideoCanvasFunctionConfig;
const incomingDirectorDesk = isRecord(value.directorDesk) ? value.directorDesk : {};
const incomingDirectorAiImport = isRecord(incomingDirectorDesk.aiImport)
? incomingDirectorDesk.aiImport
: {};
const incomingDirectorPanorama = isRecord(incomingDirectorDesk.panorama)
? incomingDirectorDesk.panorama
: {};
const incomingDirectorGaussian = isRecord(incomingDirectorDesk.gaussian)
? incomingDirectorDesk.gaussian
: {};
const incomingDirectorCrowd = isRecord(incomingDirectorDesk.crowd)
? incomingDirectorDesk.crowd
: {};
const incomingDirectorDeskUrl = String(incomingDirectorDesk.embedUrl || "").trim();
const directorDeskUrl =
incomingDirectorDeskUrl.startsWith("/") && !incomingDirectorDeskUrl.startsWith("//")
? incomingDirectorDeskUrl
: defaultVideoCanvasFunctionConfig.directorDesk.embedUrl;
const incomingFunctions = isRecord(value.functions) ? value.functions : {};
const incomingHistoryAssets = isRecord(value.historyAssets)
? value.historyAssets
: {};
const functions: Record<string, VideoCanvasFunctionDefinition> = {};
for (const [key, fallback] of Object.entries(defaultVideoCanvasFunctionConfig.functions)) {
functions[key] = normalizeFunctionDefinition(key, incomingFunctions[key], fallback);
}
for (const [key, customValue] of Object.entries(incomingFunctions)) {
if (functions[key] || !isRecord(customValue)) continue;
const fallback: VideoCanvasFunctionDefinition = {
key,
label: String(customValue.label || key),
kind: (customValue.kind as VideoCanvasFunctionKind) || "workflow",
route: (customValue.route as VideoCanvasBackendRoute) || "local",
enabled: customValue.enabled !== false,
prompt: String(customValue.prompt || ""),
generationPrompt:
typeof customValue.generationPrompt === "string"
? customValue.generationPrompt.slice(0, 10_000)
: undefined,
description: String(customValue.description || "自定义视频画布功能"),
};
functions[key] = normalizeFunctionDefinition(key, customValue, fallback);
}
return {
enabled: value.enabled !== false,
defaultImageModel:
typeof value.defaultImageModel === "string" && value.defaultImageModel.trim()
? value.defaultImageModel.trim()
: defaultVideoCanvasFunctionConfig.defaultImageModel,
defaultVideoModel:
typeof value.defaultVideoModel === "string" && value.defaultVideoModel.trim()
? value.defaultVideoModel.trim()
: defaultVideoCanvasFunctionConfig.defaultVideoModel,
defaultMultimodalModel:
typeof value.defaultMultimodalModel === "string" && value.defaultMultimodalModel.trim()
? value.defaultMultimodalModel.trim()
: defaultVideoCanvasFunctionConfig.defaultMultimodalModel,
directorDesk: {
aiImport: {
cost: normalizeClampedInteger(
incomingDirectorAiImport.cost,
defaultVideoCanvasFunctionConfig.directorDesk.aiImport.cost,
0,
9999,
),
enabled:
typeof incomingDirectorAiImport.enabled === "boolean"
? incomingDirectorAiImport.enabled
: defaultVideoCanvasFunctionConfig.directorDesk.aiImport.enabled,
functionKey:
typeof incomingDirectorAiImport.functionKey === "string" &&
incomingDirectorAiImport.functionKey.trim()
? incomingDirectorAiImport.functionKey.trim().slice(0, 100)
: defaultVideoCanvasFunctionConfig.directorDesk.aiImport.functionKey,
historyLimit: normalizeClampedInteger(
incomingDirectorAiImport.historyLimit,
defaultVideoCanvasFunctionConfig.directorDesk.aiImport.historyLimit,
1,
100,
),
localSamEndpoint: normalizeDirectorDeskHelpUrl(
incomingDirectorAiImport.localSamEndpoint,
defaultVideoCanvasFunctionConfig.directorDesk.aiImport.localSamEndpoint,
),
uploadAccept:
typeof incomingDirectorAiImport.uploadAccept === "string" &&
incomingDirectorAiImport.uploadAccept.trim()
? incomingDirectorAiImport.uploadAccept.trim().slice(0, 100)
: defaultVideoCanvasFunctionConfig.directorDesk.aiImport.uploadAccept,
},
cameraPresets: normalizeDirectorCameraPresets(incomingDirectorDesk.cameraPresets),
defaultAspectRatio: isVideoCanvasDirectorAspectRatio(
incomingDirectorDesk.defaultAspectRatio,
)
? incomingDirectorDesk.defaultAspectRatio
: defaultVideoCanvasFunctionConfig.directorDesk.defaultAspectRatio,
enabled:
typeof incomingDirectorDesk.enabled === "boolean"
? incomingDirectorDesk.enabled
: defaultVideoCanvasFunctionConfig.directorDesk.enabled,
embedUrl: directorDeskUrl,
gaussian: {
maxFileSizeMb: normalizeClampedInteger(
incomingDirectorGaussian.maxFileSizeMb,
defaultVideoCanvasFunctionConfig.directorDesk.gaussian.maxFileSizeMb,
1,
500,
),
minRaycastOpacity: Math.max(
0,
Math.min(
1,
Number.isFinite(Number(incomingDirectorGaussian.minRaycastOpacity))
? Number(incomingDirectorGaussian.minRaycastOpacity)
: defaultVideoCanvasFunctionConfig.directorDesk.gaussian.minRaycastOpacity,
),
),
uploadAccept:
typeof incomingDirectorGaussian.uploadAccept === "string" &&
incomingDirectorGaussian.uploadAccept.trim()
? incomingDirectorGaussian.uploadAccept.trim().slice(0, 200)
: defaultVideoCanvasFunctionConfig.directorDesk.gaussian.uploadAccept,
},
crowd: {
defaultColumns: normalizeClampedInteger(
incomingDirectorCrowd.defaultColumns,
defaultVideoCanvasFunctionConfig.directorDesk.crowd.defaultColumns,
1,
100,
),
defaultRows: normalizeClampedInteger(
incomingDirectorCrowd.defaultRows,
defaultVideoCanvasFunctionConfig.directorDesk.crowd.defaultRows,
1,
100,
),
defaultSpacing: Math.max(
0.1,
Math.min(
100,
Number.isFinite(Number(incomingDirectorCrowd.defaultSpacing))
? Number(Number(incomingDirectorCrowd.defaultSpacing).toFixed(2))
: defaultVideoCanvasFunctionConfig.directorDesk.crowd.defaultSpacing,
),
),
},
helpUrl: normalizeDirectorDeskHelpUrl(
incomingDirectorDesk.helpUrl,
defaultVideoCanvasFunctionConfig.directorDesk.helpUrl,
),
panorama: {
aiAspectRatio:
typeof incomingDirectorPanorama.aiAspectRatio === "string" &&
incomingDirectorPanorama.aiAspectRatio.trim()
? incomingDirectorPanorama.aiAspectRatio.trim().slice(0, 20)
: defaultVideoCanvasFunctionConfig.directorDesk.panorama.aiAspectRatio,
aiCost: normalizeClampedInteger(
incomingDirectorPanorama.aiCost,
defaultVideoCanvasFunctionConfig.directorDesk.panorama.aiCost,
0,
9999,
),
aiFunctionKey:
typeof incomingDirectorPanorama.aiFunctionKey === "string" &&
incomingDirectorPanorama.aiFunctionKey.trim()
? incomingDirectorPanorama.aiFunctionKey.trim().slice(0, 100)
: defaultVideoCanvasFunctionConfig.directorDesk.panorama.aiFunctionKey,
aiImageSize:
typeof incomingDirectorPanorama.aiImageSize === "string" &&
incomingDirectorPanorama.aiImageSize.trim()
? incomingDirectorPanorama.aiImageSize.trim().slice(0, 20)
: defaultVideoCanvasFunctionConfig.directorDesk.panorama.aiImageSize,
aiPreprocessDelayMs: normalizeClampedInteger(
incomingDirectorPanorama.aiPreprocessDelayMs,
defaultVideoCanvasFunctionConfig.directorDesk.panorama.aiPreprocessDelayMs,
0,
15_000,
),
historyLimit: normalizeClampedInteger(
incomingDirectorPanorama.historyLimit,
defaultVideoCanvasFunctionConfig.directorDesk.panorama.historyLimit,
1,
100,
),
uploadAccept:
typeof incomingDirectorPanorama.uploadAccept === "string" &&
incomingDirectorPanorama.uploadAccept.trim()
? incomingDirectorPanorama.uploadAccept.trim().slice(0, 100)
: defaultVideoCanvasFunctionConfig.directorDesk.panorama.uploadAccept,
},
posePresets: normalizeDirectorPosePresets(incomingDirectorDesk.posePresets),
showBatchCaptureControls:
typeof incomingDirectorDesk.showBatchCaptureControls === "boolean"
? incomingDirectorDesk.showBatchCaptureControls
: defaultVideoCanvasFunctionConfig.directorDesk.showBatchCaptureControls,
},
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 || "官方预设"),
authorAvatarUrl: String(
incoming.authorAvatarUrl || fallback.authorAvatarUrl || "",
),
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 || ""),
searchKeywords: String(
incoming.searchKeywords || fallback.searchKeywords || "",
),
commercial:
typeof incoming.commercial === "boolean"
? incoming.commercial
: fallback.commercial,
featured:
typeof incoming.featured === "boolean"
? incoming.featured
: fallback.featured,
} satisfies VideoCanvasEffectPreset;
})
.filter((item) => item.title && item.thumbnailUrl)
: defaultVideoCanvasEffectPresets,
historyAssets: {
defaultZoomPercent: isVideoCanvasHistoryZoomPercent(
incomingHistoryAssets.defaultZoomPercent,
)
? incomingHistoryAssets.defaultZoomPercent
: defaultVideoCanvasFunctionConfig.historyAssets.defaultZoomPercent,
enabled:
typeof incomingHistoryAssets.enabled === "boolean"
? incomingHistoryAssets.enabled
: defaultVideoCanvasFunctionConfig.historyAssets.enabled,
maxItems: normalizeClampedInteger(
incomingHistoryAssets.maxItems,
defaultVideoCanvasFunctionConfig.historyAssets.maxItems,
1,
500,
),
picker: normalizeVideoCanvasHistoryPickerConfig(
incomingHistoryAssets.picker,
),
previewAutoplay:
typeof incomingHistoryAssets.previewAutoplay === "boolean"
? incomingHistoryAssets.previewAutoplay
: defaultVideoCanvasFunctionConfig.historyAssets.previewAutoplay,
showWatermarkBadge:
typeof incomingHistoryAssets.showWatermarkBadge === "boolean"
? incomingHistoryAssets.showWatermarkBadge
: defaultVideoCanvasFunctionConfig.historyAssets.showWatermarkBadge,
watermarkBadgeText:
typeof incomingHistoryAssets.watermarkBadgeText === "string" &&
incomingHistoryAssets.watermarkBadgeText.trim()
? incomingHistoryAssets.watermarkBadgeText.trim().slice(0, 12)
: defaultVideoCanvasFunctionConfig.historyAssets.watermarkBadgeText,
},
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,
nodeCreation: normalizeVideoCanvasNodeCreationConfig(value.nodeCreation),
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,
smartStoryboard: normalizeVideoCanvasSmartStoryboardConfig(
value.smartStoryboard,
),
subjectLibrary: normalizeVideoCanvasSubjectLibraryConfig(value.subjectLibrary),
toolbox: normalizeVideoCanvasToolboxConfig(value.toolbox),
tutorialSupport: normalizeVideoCanvasTutorialSupportConfig(
value.tutorialSupport,
),
videoAdvancedSettings: {
defaultAutoValidateMaterial:
isRecord(value.videoAdvancedSettings) &&
typeof value.videoAdvancedSettings.defaultAutoValidateMaterial === "boolean"
? value.videoAdvancedSettings.defaultAutoValidateMaterial
: defaultVideoCanvasFunctionConfig.videoAdvancedSettings.defaultAutoValidateMaterial,
defaultSearchEnabled:
isRecord(value.videoAdvancedSettings) &&
typeof value.videoAdvancedSettings.defaultSearchEnabled === "boolean"
? value.videoAdvancedSettings.defaultSearchEnabled
: defaultVideoCanvasFunctionConfig.videoAdvancedSettings.defaultSearchEnabled,
},
videoGenerationParameters: normalizeVideoCanvasGenerationParametersConfig(
value.videoGenerationParameters,
),
videoModelCatalog: normalizeVideoCanvasModelCatalog(value.videoModelCatalog),
videoReferenceSelection: normalizeVideoReferenceSelectionConfig(
value.videoReferenceSelection,
),
videoAudioSeparation: normalizeVideoCanvasAudioSeparationConfig(value.videoAudioSeparation),
videoDownload: normalizeVideoCanvasVideoDownloadConfig(value.videoDownload),
videoHd: normalizeVideoCanvasVideoHdConfig(value.videoHd),
videoSubtitleErase: normalizeVideoCanvasSubtitleEraseConfig(value.videoSubtitleErase),
functions,
};
}
export function getVideoCanvasFunctionBillingCopy(
definition: Pick<VideoCanvasFunctionDefinition, "cost" | "key" | "route">
): VideoCanvasFunctionBillingCopy {
if (definition.cost !== undefined) {
return {
shortLabel: `固定 ${Math.max(0, Math.round(definition.cost))} 点`,
description: "由视频画布功能配置直接控制;任务成功后按次扣除。",
};
}
if (definition.route === "local") {
return {
shortLabel: "本地",
description: "本地画布工具,不单独扣积分。",
};
}
if (definition.key === "character-three-view" || definition.key === "multi-view-nine-grid") {
return {
shortLabel: "多视角",
description: "跟随全站“图片多视角”积分设置,不在视频画布单独改价。",
};
}
if (definition.key === "image-repaint") {
return {
shortLabel: "重绘",
description: "跟随全站“图片重绘”积分设置,不在视频画布单独改价。",
};
}
if (definition.route === "remove-bg") {
return {
shortLabel: "抠图",
description: "跟随全站“抠图”积分设置,不在视频画布单独改价。",
};
}
if (definition.route === "extend") {
return {
shortLabel: "扩图",
description: "跟随全站“图像拓展”积分设置,不在视频画布单独改价。",
};
}
if (definition.route === "enhance-pro") {
return {
shortLabel: "增强Pro",
description: "跟随全站“图像增强 Pro”积分设置,不在视频画布单独改价。",
};
}
if (definition.route === "enhance") {
return {
shortLabel: "按站点",
description: "沿用站点统一高清/增强能力,不在视频画布单独配置扣点。",
};
}
if (definition.route === "canvas-generate-video") {
return {
shortLabel: "按站点",
description: "沿用站点统一视频模型与积分规则,不在视频画布单独改价。",
};
}
if (definition.route === "canvas-workflow-analyze" || definition.route === "angle-edit") {
return {
shortLabel: "按站点",
description: "沿用站点统一模型与功能规则,不在视频画布单独改价。",
};
}
return {
shortLabel: "按站点",
description: "沿用站点统一图片生成与功能积分规则,不在视频画布单独改价。",
};
}
export function hasEnabledFixedVideoCanvasPointCosts(
config: VideoCanvasFunctionConfig,
) {
return (
config.enabled &&
Object.values(config.functions).some(
(definition) => definition.enabled && Math.max(0, Number(definition.cost || 0)) > 0,
)
);
}