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; 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 | 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 | undefined, keys: string[], ) { for (const key of keys) { const value = getDraftString(draftValues, key); if (value) return value; } return ""; } function getStoryboardTextContent( draftValues: Record | 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 | undefined, ) { const params = draftValues?.params; const paramsModel = params && typeof params === "object" && !Array.isArray(params) ? getString((params as Record).model) : ""; return paramsModel || getDraftString(draftValues, "model"); } function getStoryboardParams( draftValues: Record | undefined, ) { const params = draftValues?.params; return params && typeof params === "object" && !Array.isArray(params) ? (params as Record) : {}; } function getStoryboardParamString( draftValues: Record | 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 | 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 | 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 | 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)[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); }