feat: 完成短剧项目空间与结构化资产创作闭环 (#38)
* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP * feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环 Refs #30 * fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置 * feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环 Refs #30 --------- Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
131
web/src/lib/canvas/canvas-node-asset.ts
Normal file
131
web/src/lib/canvas/canvas-node-asset.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import type { Asset, AssetCategory, NewAsset } from "@/stores/use-asset-store";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type CanvasAssetSource = "canvas-generation" | "canvas-upload" | "canvas-manual";
|
||||
|
||||
type CanvasNodeAssetOptions = {
|
||||
canvasId: string;
|
||||
source: CanvasAssetSource;
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
export function canvasNodeToAsset(node: CanvasNodeData, options: CanvasNodeAssetOptions): NewAsset | null {
|
||||
const content = node.metadata?.content?.trim();
|
||||
if (!content) return null;
|
||||
const title = node.metadata?.prompt?.slice(0, 24) || node.title || canvasAssetFallbackTitle(node.type);
|
||||
const metadata = {
|
||||
source: options.source,
|
||||
canvasId: options.canvasId,
|
||||
nodeId: node.id,
|
||||
taskId: options.taskId || node.metadata?.taskId,
|
||||
prompt: node.metadata?.prompt,
|
||||
resourceKey: node.metadata?.storageKey,
|
||||
};
|
||||
const base = {
|
||||
title,
|
||||
coverUrl: node.type === CanvasNodeType.Image ? content : "",
|
||||
tags: node.metadata?.assetTags || [],
|
||||
category: canvasNodeAssetCategory(node),
|
||||
status: "confirmed" as const,
|
||||
source: "Canvas",
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (node.type === CanvasNodeType.Text) {
|
||||
return { ...base, kind: "text", data: { content } };
|
||||
}
|
||||
if (node.type === CanvasNodeType.Image) {
|
||||
const dataUrl = node.metadata?.storageKey ? "" : content;
|
||||
return {
|
||||
...base,
|
||||
kind: "image",
|
||||
data: {
|
||||
dataUrl,
|
||||
storageKey: node.metadata?.storageKey,
|
||||
width: node.metadata?.naturalWidth || node.width,
|
||||
height: node.metadata?.naturalHeight || node.height,
|
||||
bytes: node.metadata?.bytes || getDataUrlByteSize(dataUrl),
|
||||
mimeType: node.metadata?.mimeType || "image/png",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
return {
|
||||
...base,
|
||||
kind: "video",
|
||||
data: {
|
||||
url: content,
|
||||
storageKey: node.metadata?.storageKey,
|
||||
width: node.metadata?.naturalWidth || node.width,
|
||||
height: node.metadata?.naturalHeight || node.height,
|
||||
durationMs: node.metadata?.durationMs,
|
||||
bytes: node.metadata?.bytes || 0,
|
||||
mimeType: node.metadata?.mimeType || "video/mp4",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (node.type === CanvasNodeType.Audio) {
|
||||
return {
|
||||
...base,
|
||||
kind: "audio",
|
||||
data: {
|
||||
url: content,
|
||||
storageKey: node.metadata?.storageKey,
|
||||
durationMs: node.metadata?.durationMs,
|
||||
bytes: node.metadata?.bytes || 0,
|
||||
mimeType: node.metadata?.mimeType || "audio/mpeg",
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findCanvasNodeAsset(assets: Asset[], node: CanvasNodeData, canvasId: string, taskId?: string) {
|
||||
const explicitId = node.metadata?.assetId;
|
||||
if (explicitId) {
|
||||
const explicit = assets.find((asset) => asset.id === explicitId);
|
||||
if (explicit && sameAssetResource(explicit, node)) return explicit;
|
||||
}
|
||||
const generationTaskId = taskId || node.metadata?.taskId;
|
||||
return assets.find((asset) => {
|
||||
if (generationTaskId && asset.metadata?.taskId === generationTaskId && asset.metadata?.nodeId === node.id) return true;
|
||||
if (asset.metadata?.nodeId !== node.id) return false;
|
||||
const sourceCanvasId = asset.metadata?.canvasId;
|
||||
if (sourceCanvasId !== canvasId && (sourceCanvasId || asset.metadata?.source !== "canvas-generation")) return false;
|
||||
const nodeResourceKey = node.metadata?.storageKey;
|
||||
const assetResourceKey = typeof asset.metadata?.resourceKey === "string" ? asset.metadata.resourceKey : assetStorageKey(asset);
|
||||
return !nodeResourceKey || !assetResourceKey || nodeResourceKey === assetResourceKey;
|
||||
});
|
||||
}
|
||||
|
||||
function sameAssetResource(asset: Asset, node: CanvasNodeData) {
|
||||
const nodeResourceKey = node.metadata?.storageKey;
|
||||
const assetResourceKey = typeof asset.metadata?.resourceKey === "string" ? asset.metadata.resourceKey : assetStorageKey(asset);
|
||||
return !nodeResourceKey || !assetResourceKey || nodeResourceKey === assetResourceKey;
|
||||
}
|
||||
|
||||
function assetStorageKey(asset: Asset) {
|
||||
return asset.kind === "text" ? undefined : asset.data.storageKey;
|
||||
}
|
||||
|
||||
export function declaredCanvasNodeAssetCategory(node: CanvasNodeData): AssetCategory | undefined {
|
||||
if (node.metadata?.assetCategory) return node.metadata.assetCategory;
|
||||
if (node.metadata?.workflowKind === "character") return "character";
|
||||
if (node.metadata?.workflowKind === "scene") return "environment";
|
||||
if (node.metadata?.workflowKind === "styleboard") return "style";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function canvasNodeAssetCategory(node: CanvasNodeData): AssetCategory {
|
||||
const declared = declaredCanvasNodeAssetCategory(node);
|
||||
if (declared) return declared;
|
||||
return "other";
|
||||
}
|
||||
|
||||
function canvasAssetFallbackTitle(type: CanvasNodeType) {
|
||||
if (type === CanvasNodeType.Image) return "画布图片";
|
||||
if (type === CanvasNodeType.Video) return "画布视频";
|
||||
if (type === CanvasNodeType.Audio) return "画布音频";
|
||||
return "画布文本";
|
||||
}
|
||||
Reference in New Issue
Block a user