2081 lines
93 KiB
TypeScript
2081 lines
93 KiB
TypeScript
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,
|
||
)
|
||
);
|
||
}
|