596 lines
16 KiB
TypeScript
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);
|
|
}
|