Files
ttstd-infinite-canvas/web/src/lib/asset-record.ts
VicoHu 54e58213ba feat(assets): 素材库 - 方舟素材 ID 引用与人像认证标记
- 素材编辑弹窗新增「方舟素材 ID」(asset- 前缀校验)与「人像认证」开关,
  卡片封面显示人像认证徽章,详情抽屉展示方舟素材 ID
- 画布图片节点绑定的素材录入方舟素材 ID 后,方舟视频渠道(volcengine-ark-video
  或 Ark Plan BaseURL)生成时参考图直接提交 asset:// 引用,跳过可信素材上传同步;
  浏览器直连路径同样优先 asset://;非方舟渠道与本地回退路径不受影响
- 素材字段随 payload_json 透传,AssetBase 与 parseAssetRecord 白名单同步扩展
2026-09-11 21:41:43 +08:00

219 lines
9.8 KiB
TypeScript

import type { Asset, AssetKind, AssetStatus, AudioAsset, EntityAsset, ImageAsset, ModelAsset, TextAsset, VideoAsset } from "@/stores/use-asset-store";
import { parseAssetCategory } from "@/lib/asset-category";
const ASSET_KINDS: readonly AssetKind[] = ["text", "image", "video", "audio", "model", "entity"];
const ASSET_STATUSES: readonly AssetStatus[] = ["draft", "review", "confirmed", "archived"];
/**
* 素材持久化合同的运行时边界。它只接受能被后续业务安全消费的完整记录,绝不通过空 URL、通配 MIME 或零尺寸伪造默认值。
* 展示层可以隔离历史坏记录,但新增、更新和写回前必须经过这里的严格校验。
*/
export function parseAssetRecord(value: unknown): Asset {
if (!isRecord(value)) throw new Error("素材记录不是对象");
const id = requireTrimmedString(value, "id");
const kind = requireTrimmedString(value, "kind");
if (!ASSET_KINDS.includes(kind as AssetKind)) throw new Error(`不支持的素材类型 ${kind}`);
const title = requireString(value, "title");
const coverUrl = requireString(value, "coverUrl");
const tags = requireStringArray(value.tags, "tags");
const createdAt = requireString(value, "createdAt");
const updatedAt = requireString(value, "updatedAt");
const folderId = optionalTrimmedString(value, "folderId");
const status = optionalAssetStatus(value.status);
const primaryVersionId = optionalTrimmedString(value, "primaryVersionId");
const source = optionalString(value, "source");
const note = optionalString(value, "note");
const arkAssetId = optionalTrimmedString(value, "arkAssetId");
const portraitCertified = optionalBoolean(value, "portraitCertified");
const metadata = optionalRecord(value.metadata, "metadata");
const category = value.category === undefined ? undefined : parseAssetCategory(value.category);
const data = requireRecord(value.data, "data");
const base = {
id,
title,
coverUrl,
tags,
createdAt,
updatedAt,
...(folderId ? { folderId } : {}),
...(category ? { category } : {}),
...(status ? { status } : {}),
...(primaryVersionId ? { primaryVersionId } : {}),
...(source !== undefined ? { source } : {}),
...(note !== undefined ? { note } : {}),
...(arkAssetId ? { arkAssetId } : {}),
...(portraitCertified !== undefined ? { portraitCertified } : {}),
...(metadata ? { metadata } : {}),
};
if (kind === "text") {
return { ...base, kind, data: { content: requireString(data, "content") } } satisfies TextAsset;
}
if (kind === "image") {
const parsed = {
dataUrl: requireString(data, "dataUrl"),
storageKey: optionalString(data, "storageKey"),
width: requirePositiveNumber(data, "width"),
height: requirePositiveNumber(data, "height"),
bytes: requireNonNegativeNumber(data, "bytes"),
mimeType: requireMediaMimeType(data, "image"),
};
requireMediaLocator(parsed.dataUrl, parsed.storageKey, "图片", "dataUrl");
return { ...base, kind, data: parsed } satisfies ImageAsset;
}
if (kind === "video") {
const parsed = {
url: requireString(data, "url"),
storageKey: optionalString(data, "storageKey"),
width: requirePositiveNumber(data, "width"),
height: requirePositiveNumber(data, "height"),
durationMs: optionalNonNegativeNumber(data, "durationMs"),
hasAudio: optionalBoolean(data, "hasAudio"),
bytes: requireNonNegativeNumber(data, "bytes"),
mimeType: requireMediaMimeType(data, "video"),
};
requireMediaLocator(parsed.url, parsed.storageKey, "视频", "url");
return { ...base, kind, data: parsed } satisfies VideoAsset;
}
if (kind === "audio") {
const parsed = {
url: requireString(data, "url"),
storageKey: optionalString(data, "storageKey"),
durationMs: optionalNonNegativeNumber(data, "durationMs"),
bytes: requireNonNegativeNumber(data, "bytes"),
mimeType: requireMediaMimeType(data, "audio"),
};
requireMediaLocator(parsed.url, parsed.storageKey, "音频", "url");
return { ...base, kind, data: parsed } satisfies AudioAsset;
}
if (kind === "model") {
const parsed = {
url: requireString(data, "url"),
storageKey: optionalString(data, "storageKey"),
bytes: requireNonNegativeNumber(data, "bytes"),
mimeType: requireConcreteMimeType(data),
fileName: requireTrimmedString(data, "fileName"),
};
requireMediaLocator(parsed.url, parsed.storageKey, "模型", "url");
return { ...base, kind, data: parsed } satisfies ModelAsset;
}
if (kind === "entity") {
return { ...base, kind, data: { definition: requireRecord(data.definition, "definition") } } satisfies EntityAsset;
}
throw new Error(`不支持的素材类型 ${kind}`);
}
export function parseAssetRecordList(values: unknown): Asset[] {
if (!Array.isArray(values)) throw new Error("素材列表无效");
return values.map((item, index) => {
try {
return parseAssetRecord(item);
} catch (error) {
const id = isRecord(item) && typeof item.id === "string" && item.id.trim() ? item.id : `#${index}`;
throw new Error(`素材 ${id} 合同无效:${error instanceof Error ? error.message : String(error)}`);
}
});
}
function requireMediaLocator(primary: string, storageKey: string | undefined, label: string, primaryKey: string) {
if (!primary.trim() && !storageKey?.trim()) {
throw new Error(`${label}素材缺少 ${primaryKey} 或 storageKey`);
}
}
function requireString(record: Record<string, unknown>, key: string): string {
const value = record[key];
if (typeof value !== "string") throw new Error(`素材字段 ${key} 必须是字符串`);
return value;
}
function requireTrimmedString(record: Record<string, unknown>, key: string): string {
const value = requireString(record, key).trim();
if (!value) throw new Error(`素材字段 ${key} 不能为空`);
return value;
}
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) return undefined;
if (typeof record[key] !== "string") throw new Error(`素材字段 ${key} 必须是字符串`);
return record[key] as string;
}
function optionalTrimmedString(record: Record<string, unknown>, key: string): string | undefined {
const value = optionalString(record, key)?.trim();
return value || undefined;
}
function requireStringArray(value: unknown, key: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
throw new Error(`素材字段 ${key} 必须是字符串数组`);
}
return value;
}
function requireRecord(value: unknown, key: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`素材字段 ${key} 必须是对象`);
return value;
}
function optionalRecord(value: unknown, key: string): Record<string, unknown> | undefined {
if (value === undefined) return undefined;
return requireRecord(value, key);
}
function requireNonNegativeNumber(record: Record<string, unknown>, key: string): number {
const value = record[key];
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new Error(`素材字段 ${key} 必须是非负数字`);
}
return value;
}
function requirePositiveNumber(record: Record<string, unknown>, key: string): number {
const value = requireNonNegativeNumber(record, key);
if (value === 0) throw new Error(`素材字段 ${key} 必须大于 0`);
return value;
}
function requireConcreteMimeType(record: Record<string, unknown>): string {
const mimeType = requireTrimmedString(record, "mimeType").toLowerCase();
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mimeType) || mimeType.endsWith("/*")) {
throw new Error("素材字段 mimeType 必须是具体 MIME 类型");
}
return mimeType;
}
function requireMediaMimeType(record: Record<string, unknown>, kind: "image" | "video" | "audio"): string {
const mimeType = requireConcreteMimeType(record);
// application/octet-stream 是诚实的“类型未知”;除此之外必须与素材种类一致,
// 防止图片记录携带 audio/* 等跨类型元数据后在下载、转码阶段才暴露错误。
if (mimeType !== "application/octet-stream" && !mimeType.startsWith(`${kind}/`)) {
throw new Error(`素材字段 mimeType 与 ${kind} 类型不匹配`);
}
return mimeType;
}
function optionalNonNegativeNumber(record: Record<string, unknown>, key: string): number | undefined {
if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) return undefined;
return requireNonNegativeNumber(record, key);
}
function optionalBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
if (!Object.prototype.hasOwnProperty.call(record, key) || record[key] === undefined) return undefined;
if (typeof record[key] !== "boolean") throw new Error(`素材字段 ${key} 必须是布尔值`);
return record[key] as boolean;
}
function optionalAssetStatus(value: unknown): AssetStatus | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string" || !ASSET_STATUSES.includes(value as AssetStatus)) {
throw new Error("素材 status 无效");
}
return value as AssetStatus;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}