ai-chatbot/lib/video-canvas/storyboard-overview.ts

596 lines
16 KiB
TypeScript

export const VIDEO_CANVAS_VIEW_MODE_KEY = "videoCanvasViewMode";
export type VideoCanvasViewMode = "storyboard" | "workflow";
export type VideoCanvasStoryboardExpandedPanel = "image" | "video" | null;
export type VideoCanvasStoryboardVideoCategory = "clip" | "film";
export type VideoCanvasStoryboardVideoFilter =
| "all"
| VideoCanvasStoryboardVideoCategory;
export const VIDEO_CANVAS_STORYBOARD_VIDEO_FILTER_OPTIONS: Array<{
label: string;
value: VideoCanvasStoryboardVideoFilter;
}> = [
{ label: "全部", value: "all" },
{ label: "成片", value: "film" },
{ label: "片段", value: "clip" },
];
export type VideoCanvasStoryboardColumnLayout = {
image: 1 | 2;
text: 0 | 1;
video: 0 | 1 | 2;
};
export function getVideoCanvasStoryboardColumnLayout(
expandedPanel: VideoCanvasStoryboardExpandedPanel,
): VideoCanvasStoryboardColumnLayout {
if (expandedPanel === "image") {
return { image: 2, text: 1, video: 0 };
}
if (expandedPanel === "video") {
return { image: 1, text: 0, video: 2 };
}
return { image: 1, text: 1, video: 1 };
}
export type VideoCanvasKeyElementCategory =
| "character"
| "object"
| "other"
| "scene";
export type VideoCanvasKeyElementFilter =
| "all"
| VideoCanvasKeyElementCategory;
export const VIDEO_CANVAS_KEY_ELEMENT_CATEGORY_OPTIONS: Array<{
label: string;
value: VideoCanvasKeyElementCategory;
}> = [
{ label: "人物", value: "character" },
{ label: "场景", value: "scene" },
{ label: "物品", value: "object" },
{ label: "其他", value: "other" },
];
export function getVideoCanvasKeyElementCategoryLabel(
category: VideoCanvasKeyElementCategory,
) {
return (
VIDEO_CANVAS_KEY_ELEMENT_CATEGORY_OPTIONS.find(
(option) => option.value === category,
)?.label || "其他"
);
}
export type VideoCanvasStoryboardSourceNode = {
draftValues?: Record<string, unknown>;
id: string;
inputType?: "audio" | "image" | "text" | "video";
kind?: string;
subtitle?: string;
title?: string;
};
export type VideoCanvasStoryboardItem = {
aspectRatio: string;
contentType: string;
durationSeconds: number;
generationStatus: string;
id: string;
imageCount: number;
imageQuality: string;
imageSize: string;
keyElementCategory?: VideoCanvasKeyElementCategory;
mediaType: "audio" | "image" | "text" | "video";
mediaUrl: string;
modelName: string;
modelRef: string;
prompt: string;
previewUrl: string;
referenceImages: string[];
resolutionLabel: string;
styleLabel: string;
subtitle: string;
textContent: string;
title: string;
videoCategory?: VideoCanvasStoryboardVideoCategory;
};
export type VideoCanvasStoryboardCompositionItem = {
id: string;
title: string;
};
export type VideoCanvasStoryboardProjection = {
audio: VideoCanvasStoryboardItem[];
image: VideoCanvasStoryboardItem[];
keyElements: VideoCanvasStoryboardItem[];
text: VideoCanvasStoryboardItem[];
video: VideoCanvasStoryboardItem[];
};
function getString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function getDraftString(
draftValues: Record<string, unknown> | undefined,
key: string,
) {
return getString(draftValues?.[key]);
}
export function isVideoCanvasStoryboardCompositionNode(
node: VideoCanvasStoryboardSourceNode,
) {
const action = getDraftString(node.draftValues, "action").toUpperCase();
const nodeType = getDraftString(node.draftValues, "nodeType").toLowerCase();
return action === "VIDEO_CLIP_RESOURCE" || nodeType === "video-clip";
}
export function projectVideoCanvasStoryboardCompositions(
nodes: VideoCanvasStoryboardSourceNode[],
): VideoCanvasStoryboardCompositionItem[] {
return nodes
.filter(isVideoCanvasStoryboardCompositionNode)
.map((node) => ({
id: node.id,
title: getString(node.title) || "视频合成",
}));
}
function getStoryboardMediaUrl(
node: VideoCanvasStoryboardSourceNode,
mediaType: VideoCanvasStoryboardItem["mediaType"],
) {
const draftValues = node.draftValues;
const direct = getDraftString(draftValues, "mediaUrl");
if (direct) return direct;
const urls = draftValues?.url;
const firstUrl =
Array.isArray(urls) && typeof urls[0] === "string"
? urls[0].trim()
: "";
if (firstUrl) return firstUrl;
if (mediaType === "video") return "";
if (mediaType === "audio") {
return getDraftString(draftValues, "sourceUrl");
}
return (
getDraftString(draftValues, "thumbnailUrl") ||
getDraftString(draftValues, "resultImage") ||
getDraftString(draftValues, "sourceUrl")
);
}
function getStoryboardPreviewUrl(
node: VideoCanvasStoryboardSourceNode,
mediaType: VideoCanvasStoryboardItem["mediaType"],
) {
const draftValues = node.draftValues;
const preview = getFirstDraftString(draftValues, [
"thumbnailUrl",
"posterUrl",
"previewImageUrl",
"coverUrl",
"poster",
"resultImage",
]);
if (preview) return preview;
return mediaType === "image" ? getStoryboardMediaUrl(node, mediaType) : "";
}
function getFirstDraftString(
draftValues: Record<string, unknown> | undefined,
keys: string[],
) {
for (const key of keys) {
const value = getDraftString(draftValues, key);
if (value) return value;
}
return "";
}
function getStoryboardTextContent(
draftValues: Record<string, unknown> | undefined,
) {
const content = draftValues?.content;
if (Array.isArray(content)) {
const firstText = content.find(
(value): value is string => typeof value === "string" && Boolean(value.trim()),
);
if (firstText) return firstText.trim();
}
return getFirstDraftString(draftValues, [
"textContent",
"resultText",
"outputText",
"markdown",
"script",
"text",
]);
}
function getStoryboardModelRef(
draftValues: Record<string, unknown> | undefined,
) {
const params = draftValues?.params;
const paramsModel =
params && typeof params === "object" && !Array.isArray(params)
? getString((params as Record<string, unknown>).model)
: "";
return paramsModel || getDraftString(draftValues, "model");
}
function getStoryboardParams(
draftValues: Record<string, unknown> | undefined,
) {
const params = draftValues?.params;
return params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
}
function getStoryboardParamString(
draftValues: Record<string, unknown> | undefined,
keys: string[],
) {
const params = getStoryboardParams(draftValues);
for (const key of keys) {
const value = getString(params[key]) || getDraftString(draftValues, key);
if (value) return value;
}
return "";
}
function getStoryboardReferenceImages(
draftValues: Record<string, unknown> | undefined,
) {
const params = getStoryboardParams(draftValues);
const candidates = [
draftValues?.referenceImage,
...(Array.isArray(draftValues?.referenceImages)
? draftValues.referenceImages
: []),
params.referenceImage,
...(Array.isArray(params.referenceImages) ? params.referenceImages : []),
];
return Array.from(
new Set(
candidates
.map((value) => getString(value))
.filter(Boolean),
),
).slice(0, 14);
}
function getStoryboardContentType(
mediaType: VideoCanvasStoryboardItem["mediaType"],
draftValues: Record<string, unknown> | undefined,
) {
const explicit = getFirstDraftString(draftValues, [
"contentType",
"mimeType",
"fileType",
]);
if (explicit.includes("/")) return explicit;
if (mediaType === "audio") return "audio/wav";
if (mediaType === "video") return "video/mp4";
if (mediaType === "text") return "text/plain";
return "image/jpeg";
}
function getStoryboardResolutionLabel(
draftValues: Record<string, unknown> | undefined,
) {
const explicit = getFirstDraftString(draftValues, [
"resolutionLabel",
"resolution",
"imageSize",
"aspectRatio",
"ratio",
]);
if (explicit) return explicit;
const width = Number(draftValues?.originalWidth || draftValues?.width || 0);
const height = Number(draftValues?.originalHeight || draftValues?.height || 0);
return Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0
? `${Math.round(width)} x ${Math.round(height)}`
: "";
}
function resolveStoryboardVideoCategory(
node: VideoCanvasStoryboardSourceNode,
): VideoCanvasStoryboardVideoCategory {
const explicitCategory = getFirstDraftString(node.draftValues, [
"storyboardVideoCategory",
"storyboardVideoType",
"videoAssetCategory",
"videoAssetKind",
]).toLowerCase();
if (/^(film|final|finished|master|成片)$/.test(explicitCategory)) {
return "film";
}
if (/^(clip|fragment|segment|shot|片段)$/.test(explicitCategory)) {
return "clip";
}
if (
node.draftValues?.isStoryboardFilm === true ||
node.draftValues?.isFinalVideo === true
) {
return "film";
}
const action = getDraftString(node.draftValues, "action").toUpperCase();
if (
action === "VIDEO_EDITOR_EXPORT" ||
action === "VIDEO_STORYBOARD_EXPORT" ||
action === "VIDEO_COMPOSITE_EXPORT" ||
action === "VIDEO_MERGE_EXPORT"
) {
return "film";
}
return "clip";
}
function resolveStoryboardMediaType(
node: VideoCanvasStoryboardSourceNode,
): VideoCanvasStoryboardItem["mediaType"] | null {
const draftValues = node.draftValues;
const explicitType =
getDraftString(draftValues, "mediaType") || node.inputType || "";
if (
explicitType === "audio" ||
explicitType === "image" ||
explicitType === "text" ||
explicitType === "video"
) {
return explicitType;
}
const kind = getString(node.kind).toLowerCase();
const action = getDraftString(draftValues, "action").toUpperCase();
const searchable = `${getString(node.title)} ${getString(node.subtitle)}`;
if (
kind === "text" ||
kind === "script" ||
kind === "video-story" ||
action.includes("TEXT") ||
/文本|提示词|剧本|脚本/.test(searchable)
) {
return "text";
}
if (kind === "audio" || action.includes("AUDIO") || /音频|音乐|配音/.test(searchable)) {
return "audio";
}
if (action.includes("VIDEO") || /视频|成片|成品/.test(searchable)) {
return "video";
}
if (
kind === "media" ||
kind.startsWith("material-") ||
action.includes("IMAGE") ||
/图片|图像|镜头|分镜|角色|场景|人物/.test(searchable)
) {
return "image";
}
return null;
}
function isKeyElement(
node: VideoCanvasStoryboardSourceNode,
mediaType: VideoCanvasStoryboardItem["mediaType"],
) {
if (mediaType !== "image") return false;
const draftValues = node.draftValues;
const explicitCategory = [
getDraftString(draftValues, "keyElementType"),
getDraftString(draftValues, "storyboardCategory"),
getDraftString(draftValues, "category"),
getDraftString(draftValues, "elementType"),
getDraftString(draftValues, "materialKind"),
].join(" ");
const action = getDraftString(draftValues, "action").toUpperCase();
const kind = getString(node.kind).toLowerCase();
if (
draftValues?.keyElement === true ||
/关键元素|角色|人物|场景|地点|物品|道具|其他|character|person|role|scene|location|object|item|prop|other/i.test(
explicitCategory,
) ||
kind.startsWith("material-") ||
action.startsWith("MATERIAL_")
) {
return true;
}
const title = getString(node.title);
if (!title || /^(上传|图片|图像|镜头|分镜|成品|结果|首帧|尾帧|关键帧)/.test(title)) {
return false;
}
return /角色|人物|场景|地点|道具/.test(`${title} ${getString(node.subtitle)}`);
}
function resolveKeyElementCategory(
node: VideoCanvasStoryboardSourceNode,
): VideoCanvasKeyElementCategory {
const draftValues = node.draftValues;
const explicitCategory = [
getDraftString(draftValues, "keyElementType"),
getDraftString(draftValues, "elementType"),
getDraftString(draftValues, "storyboardCategory"),
getDraftString(draftValues, "category"),
getDraftString(draftValues, "materialKind"),
getDraftString(draftValues, "roleType"),
]
.join(" ")
.toLowerCase();
const action = getDraftString(draftValues, "action").toLowerCase();
const kind = getString(node.kind).toLowerCase();
const searchable = `${getString(node.title)} ${getString(node.subtitle)}`;
if (/人物|角色|演员|character|person|people|actor|role/.test(explicitCategory)) {
return "character";
}
if (/场景|地点|环境|scene|location|environment/.test(explicitCategory)) {
return "scene";
}
if (/物品|道具|物件|object|item|prop/.test(explicitCategory)) {
return "object";
}
if (/其他|other/.test(explicitCategory)) return "other";
if (kind === "material-role" || action === "material_role") {
return "character";
}
if (/物品|道具|刀|剑|枪|杯|瓶|书/.test(searchable)) {
return "object";
}
if (/场景|地点|夜景|白昼|办公室|街市|宫殿/.test(searchable)) {
return "scene";
}
if (/角色|人物|男主|女主|少女|少年|男子|女士/.test(searchable)) {
return "character";
}
return "other";
}
export function resolveVideoCanvasViewMode(
canvasData: unknown,
): VideoCanvasViewMode {
if (!canvasData || typeof canvasData !== "object" || Array.isArray(canvasData)) {
return "workflow";
}
return (canvasData as Record<string, unknown>)[VIDEO_CANVAS_VIEW_MODE_KEY] ===
"storyboard"
? "storyboard"
: "workflow";
}
export function projectVideoCanvasStoryboard(
nodes: VideoCanvasStoryboardSourceNode[],
): VideoCanvasStoryboardProjection {
const projection: VideoCanvasStoryboardProjection = {
audio: [],
image: [],
keyElements: [],
text: [],
video: [],
};
for (const node of nodes) {
if (isVideoCanvasStoryboardCompositionNode(node)) continue;
const mediaType = resolveStoryboardMediaType(node);
if (!mediaType) continue;
const item: VideoCanvasStoryboardItem = {
aspectRatio:
getStoryboardParamString(node.draftValues, [
"aspectRatio",
"ratio",
]) || "16:9",
contentType: getStoryboardContentType(mediaType, node.draftValues),
durationSeconds: Math.max(
0,
Number(node.draftValues?.durationSeconds || 0) || 0,
),
generationStatus: getDraftString(
node.draftValues,
"generationStatus",
),
id: node.id,
imageCount: Math.max(
1,
Math.min(
4,
Math.round(
Number(
getStoryboardParams(node.draftValues).imageCount ||
node.draftValues?.imageCount ||
1,
) || 1,
),
),
),
imageQuality:
getStoryboardParamString(node.draftValues, [
"imageQuality",
"quality",
]) || "标准画质",
imageSize:
getStoryboardParamString(node.draftValues, [
"imageSize",
"resolution",
]) || "2K",
mediaType,
mediaUrl: getStoryboardMediaUrl(node, mediaType),
modelName: getFirstDraftString(node.draftValues, [
"modelName",
"modelDisplayName",
"displayName",
"providerName",
"model",
]),
modelRef: getStoryboardModelRef(node.draftValues),
prompt: getFirstDraftString(node.draftValues, [
"prompt",
"outputPrompt",
"positivePrompt",
"description",
]),
previewUrl: getStoryboardPreviewUrl(node, mediaType),
referenceImages: getStoryboardReferenceImages(node.draftValues),
resolutionLabel: getStoryboardResolutionLabel(node.draftValues),
styleLabel: getFirstDraftString(node.draftValues, [
"styleLabel",
"visualStyle",
"style",
]),
subtitle: getString(node.subtitle),
textContent: getStoryboardTextContent(node.draftValues),
title: getString(node.title) || "未命名节点",
videoCategory:
mediaType === "video"
? resolveStoryboardVideoCategory(node)
: undefined,
};
if (isKeyElement(node, mediaType)) {
projection.keyElements.push({
...item,
keyElementCategory: resolveKeyElementCategory(node),
});
continue;
}
projection[mediaType].push(item);
}
return projection;
}
export function filterVideoCanvasStoryboardVideos(
items: VideoCanvasStoryboardItem[],
filter: VideoCanvasStoryboardVideoFilter,
) {
if (filter === "all") return items;
return items.filter((item) => item.videoCategory === filter);
}
export function filterVideoCanvasKeyElements(
items: VideoCanvasStoryboardItem[],
filter: VideoCanvasKeyElementFilter,
) {
return filter === "all"
? items
: items.filter((item) => item.keyElementCategory === filter);
}