feat(assets): 素材库 - 方舟素材 ID 引用与人像认证标记

- 素材编辑弹窗新增「方舟素材 ID」(asset- 前缀校验)与「人像认证」开关,
  卡片封面显示人像认证徽章,详情抽屉展示方舟素材 ID
- 画布图片节点绑定的素材录入方舟素材 ID 后,方舟视频渠道(volcengine-ark-video
  或 Ark Plan BaseURL)生成时参考图直接提交 asset:// 引用,跳过可信素材上传同步;
  浏览器直连路径同样优先 asset://;非方舟渠道与本地回退路径不受影响
- 素材字段随 payload_json 透传,AssetBase 与 parseAssetRecord 白名单同步扩展
This commit is contained in:
VicoHu
2026-09-11 14:07:31 +08:00
committed by ddCat
parent 900420390e
commit 54e58213ba
8 changed files with 75 additions and 11 deletions

View File

@@ -1628,3 +1628,11 @@ v2(用户反馈首版难看后重做):菜单改用 antd Dropdown(placeme
- [ ] 工具栏「截图当前视角」与「导出四向视图」:源节点右侧出现派生图片节点(四向视图为 2×2 带方向标注),多次导出按列错开不重叠;导出后视角自动还原。
- [ ] 「全屏环视」:覆盖层展开,滚轮缩放视场角生效;退出全屏回到节点内环视且视角保留;展开期间节点体显示静态预览。
- [ ] 分享只读页(未提供导出动作)不显示截图/四宫格按钮,仅保留环视、重置与全屏;明暗主题与性能模式(仅预览)行为不变。
## 方舟可信素材失败重试与素材库方舟素材 ID(2026-09-11)
后端:方舟可信素材同步失败不再永久缓存——素材组未建成(素材组 ID 与素材 ID 均为空)的失败 binding 下次同步直接重试,素材组已建、素材未建时仅重试创建素材阶段的失败;审核拒绝(ArkAssetID 非空)保持终态;重试复用已建素材组;控制面 HTTP 非 2xx 时透传方舟 ResponseMetadata.Error 的原始 Code/Message(如 SubscriptionRequired),不再统一误报为「模型服务鉴权失败」;管理页 Region 输入补充仅 cn-beijing 开放的说明。前端:素材库编辑弹窗新增「方舟素材 ID」与「人像认证」两个字段(卡片封面显示「人像认证」徽章、详情抽屉显示方舟素材 ID);方舟视频渠道(volcengine-ark-video 或 Ark Plan BaseURL)生成时,引用图片节点绑定的素材若已录入方舟素材 ID,任务参考图直接提交 `asset://<ID>` 跳过上传同步(后端任务与浏览器直连路径一致);任务重跑按存储引用还原时会回退到常规上传路径。后端 go test(排除 main 存量失败用例 TestUserAssetsBatchIsBoundedAndScoped 与 protocol 插件清单校验)与前端构建通过,需浏览器验收:
- [ ] 管理页断开网络或把 Region 暂改为 cn-shanghai 后在画布上传方舟素材失败,恢复后再次上传直接发起新请求成功,无需清理数据库 binding 记录。
- [ ] 套餐未开通等上游 403 场景,上传失败提示包含方舟原始错误码(如 SubscriptionRequired),不再是「模型服务鉴权失败」。
- [ ] 素材库编辑图片素材:可填写 asset- 开头的方舟素材 ID、勾选人像认证,保存后刷新页面两字段仍在;卡片封面出现「人像认证」徽章,详情抽屉显示方舟素材 ID 标签;非法 ID(不以 asset- 开头)被表单拦截。
- [ ] 画布图片节点绑定已录入方舟素材 ID 的素材后连接视频节点,用方舟渠道生成:任务参考图为 asset:// 引用、跳过「同步方舟可信素材」;非方舟渠道生成不受影响,仍走原参考图路径。

View File

@@ -71,7 +71,7 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
const reason = status === "stale" ? "输入或方式已变化" : status === "processing" ? "仍在处理中" : "尚未完成本地转换";
throw new Error(`转换节点「${pendingConversion.title || pendingConversion.id}」${reason},完成后才能执行下游生成`);
}
const connectedInputs = buildNodeGenerationInputs(nodeId, nodes, connections);
const connectedInputs = withArkAssetReferenceInputs(buildNodeGenerationInputs(nodeId, nodes, connections), nodes, assets);
const sourceNode = nodes.find((node) => node.id === nodeId);
const portraitTextureInput = sourceNode?.type === CanvasNodeType.Image && sourceNode.metadata?.content && sourceNode.metadata?.portraitTexture
? (() => {
@@ -81,7 +81,7 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
: [];
// 显式 @ 引用必须与提示词面板展示的资源集合一致;默认自动输入仍只取入边,
// 避免已有图片在没有 @图片N 时被悄悄当作自身参考图。
const mentionInputs = mergeGenerationInputs(buildNodeMentionGenerationInputs(nodeId, nodes, connections), portraitTextureInput, buildAssetGenerationInputs(assets));
const mentionInputs = withArkAssetReferenceInputs(mergeGenerationInputs(buildNodeMentionGenerationInputs(nodeId, nodes, connections), portraitTextureInput, buildAssetGenerationInputs(assets)), nodes, assets);
const storyboardInputs = getConnectedStoryboardRows(nodeId, nodes, connections);
assertResolvableGenerationMentions(prompt, mentionInputs);
const hasExplicitResourceMention = hasResolvableGenerationMention(prompt, mentionInputs);
@@ -407,7 +407,7 @@ function buildAssetGenerationInputs(assets: Asset[]): NodeGenerationInput[] {
return assets.flatMap((asset): NodeGenerationInput[] => {
const nodeId = `asset:${asset.id}`;
if (asset.kind === "text") return [{ nodeId, type: "text", title: asset.title, text: asset.data.content }];
if (asset.kind === "image") return [{ nodeId, type: "image", title: asset.title, image: { id: asset.id, name: asset.title, type: asset.data.mimeType, dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height } }];
if (asset.kind === "image") return [{ nodeId, type: "image", title: asset.title, image: { id: asset.id, name: asset.title, type: asset.data.mimeType, dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height, ...(asset.arkAssetId ? { arkAssetId: asset.arkAssetId } : {}) } }];
if (asset.kind === "video") return [{ nodeId, type: "video", title: asset.title, previewUrl: canvasVideoAssetPreviewUrl(asset.data.url, asset.coverUrl), video: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs } }];
if (asset.kind === "audio") return [{ nodeId, type: "audio", title: asset.title, audio: { id: asset.id, name: asset.title, type: asset.data.mimeType, url: asset.data.url, storageKey: asset.data.storageKey, bytes: asset.data.bytes, durationMs: asset.data.durationMs } }];
if (asset.kind === "entity" && asset.category === "character") return [{ nodeId, type: "character", title: asset.title, character: { nodeId, assetId: asset.id, requestedVersionId: asset.primaryVersionId } }];
@@ -415,6 +415,20 @@ function buildAssetGenerationInputs(assets: Asset[]): NodeGenerationInput[] {
});
}
// 图片节点绑定的素材若已录入方舟素材 ID,把 ID 附加到参考图上;是否改用 asset:// 引用
// 由 generation-task 按视频渠道决定,非方舟渠道与本地回退路径不受影响。
function withArkAssetReferenceInputs(inputs: NodeGenerationInput[], nodes: CanvasNodeData[], assets: Asset[]): NodeGenerationInput[] {
if (!assets.length) return inputs;
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
return inputs.map((input) => {
if (input.type !== "image" || !input.image || input.image.arkAssetId) return input;
const boundAssetId = nodeById.get(input.nodeId)?.metadata?.assetId;
const arkAssetId = boundAssetId ? assetById.get(boundAssetId)?.arkAssetId : undefined;
return arkAssetId ? { ...input, image: { ...input.image, arkAssetId } } : input;
});
}
function getConnectedStoryboardRows(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
const targetNodeIds = new Set([nodeId]);
connections.forEach((connection) => {

View File

@@ -24,6 +24,8 @@ export function parseAssetRecord(value: unknown): Asset {
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");
@@ -40,6 +42,8 @@ export function parseAssetRecord(value: unknown): Asset {
...(primaryVersionId ? { primaryVersionId } : {}),
...(source !== undefined ? { source } : {}),
...(note !== undefined ? { note } : {}),
...(arkAssetId ? { arkAssetId } : {}),
...(portraitCertified !== undefined ? { portraitCertified } : {}),
...(metadata ? { metadata } : {}),
};

View File

@@ -9,6 +9,7 @@ import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage }
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { Switch } from "@/components/ui/base/switch";
import { saveAs } from "file-saver";
import { cn } from "@/lib/utils";
@@ -40,6 +41,8 @@ type AssetFormValues = {
source?: string;
note?: string;
content?: string;
arkAssetId?: string;
portraitCertified?: boolean;
};
type ImageDraft = ImageAsset["data"] | null;
@@ -254,7 +257,7 @@ export default function AssetsPage() {
setImageUploading(false);
setImageUploadProgress(null);
setFormKind("text");
form.setFieldsValue({ kind: "text", category: "other", folderId: folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : "", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" });
form.setFieldsValue({ kind: "text", category: "other", folderId: folderFilter !== "all" && folderFilter !== "uncategorized" ? folderFilter : "", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "", arkAssetId: "", portraitCertified: false });
setIsAssetOpen(true);
};
@@ -275,6 +278,8 @@ export default function AssetsPage() {
source: asset.source,
note: asset.note,
content: asset.kind === "text" ? asset.data.content : "",
arkAssetId: asset.arkAssetId || "",
portraitCertified: asset.portraitCertified === true,
});
setIsAssetOpen(true);
};
@@ -311,6 +316,8 @@ export default function AssetsPage() {
tags: values.tags || [],
source: values.source?.trim(),
note: values.note?.trim(),
arkAssetId: values.arkAssetId?.trim() || undefined,
portraitCertified: values.portraitCertified || undefined,
metadata: editingAsset?.metadata || { source: "manual" },
};
@@ -820,6 +827,14 @@ export default function AssetsPage() {
<Form.Item name="tags" label="标签">
<Select mode="tags" tokenSeparators={[",", ","]} placeholder="输入标签后回车" />
</Form.Item>
<div className="grid gap-4 sm:grid-cols-2">
<Form.Item name="arkAssetId" label="方舟素材 ID" rules={[{ pattern: /^asset-[A-Za-z0-9-]+$/, message: "请输入 asset- 开头的方舟素材 ID" }]}>
<Input autoComplete="off" allowClear placeholder="asset-…,需为本人或被授权可用的方舟素材" />
</Form.Item>
<Form.Item name="portraitCertified" label="人像认证" valuePropName="checked" extra="标记已通过火山方舟实人认证的真人人像素材">
<Switch aria-label="人像认证" />
</Form.Item>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Form.Item name="source" label="来源">
<Input placeholder="手动添加 / 画布 / 任务中心" />
@@ -1142,6 +1157,7 @@ function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuIt
{kind ? assetKindLabel(kind) : "素材"}
</span>
{isTrash ? <span className="assets-cover-badge is-category !bg-amber-500/85 !text-white">回收站</span> : <span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>}
{asset.portraitCertified ? <span className="assets-cover-badge is-category">人像认证</span> : null}
</span>
{clock ? <span className="assets-cover-clock">{clock}</span> : null}
<input type="checkbox" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelect(event.target.checked)} className="assets-select-check" aria-label={`选择 ${asset.title}`} />
@@ -1369,6 +1385,11 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
{tag}
</Tag>
))}
{asset.arkAssetId ? (
<Tag className="m-0" color="geekblue" title="火山方舟素材 ID,生成视频时可直接 asset:// 引用">
方舟 {asset.arkAssetId}
</Tag>
) : null}
<StorageTag asset={asset} />
</div>
<div className="asset-archive-facts">

View File

@@ -7,6 +7,7 @@ import { isLocalDreaminaBackgroundTask, localDreaminaTaskId, projectLocalDreamin
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import { grokImagePromptLimitError } from "@/lib/grok-image-prompt-limit";
import { resolveGenerationWorkflowExecution, type GenerationWorkflowExecution } from "@/lib/generation-workflow-execution";
import { isArkPlanBaseUrl } from "@/lib/seedance-video";
import { resolveVideoOperation } from "@/lib/model-selection";
import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store";
@@ -115,7 +116,7 @@ export async function runBackendGenerationTask(
);
}
assertBackendRuntimeConfigured(config, mode);
const prepared = await prepareGenerationReferences({ referenceImages, referenceVideos, referenceAudios, mask });
const prepared = await prepareGenerationReferences({ config, referenceImages, referenceVideos, referenceAudios, mask });
throwIfAborted(signal);
return createAndWaitGenerationTask({ projectId, mode, prompt, config, referenceImages, referenceVideos, referenceAudios, textHistory, signal, metadata, onTaskUpdate, onTextDelta, streamText, enableThinking, clientOperationId, retryOf, attemptGroupId }, prepared, dependencies);
}
@@ -432,18 +433,26 @@ function assertClientPromptLimit(mode: BackendGenerationMode, prompt: string, co
}
async function prepareGenerationReferences({
config,
referenceImages = [],
referenceVideos = [],
referenceAudios = [],
mask,
}: Pick<BackendGenerationTaskOptions, "referenceImages" | "referenceVideos" | "referenceAudios" | "mask">): Promise<PreparedGenerationReferences> {
const preparedImages = await Promise.all(referenceImages.map(prepareBackendImageReference));
}: Pick<BackendGenerationTaskOptions, "config" | "referenceImages" | "referenceVideos" | "referenceAudios" | "mask">): Promise<PreparedGenerationReferences> {
const preferArkAssetUrl = usesArkVideoAssetReference(config);
const preparedImages = await Promise.all(referenceImages.map((image) => prepareBackendImageReference(image, preferArkAssetUrl)));
const preparedVideos = await Promise.all(referenceVideos.map(prepareBackendMediaReference));
const preparedAudios = await Promise.all(referenceAudios.map(prepareBackendMediaReference));
const preparedMask = mask ? await prepareBackendImageReference(mask) : undefined;
const preparedMask = mask ? await prepareBackendImageReference(mask, false) : undefined;
return { referenceImages: preparedImages, referenceVideos: preparedVideos, referenceAudios: preparedAudios, mask: preparedMask };
}
// 与后端 isArkPrivateAssetVideoConfig 对齐:方舟视频渠道允许参考图直接 asset:// 引用,
// 跳过可信素材上传同步(适合已录入方舟素材 ID 的素材,例如被授权的真人像素材)。
function usesArkVideoAssetReference(config: AiConfig) {
return resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-ark-video" || isArkPlanBaseUrl(config.baseUrl || "");
}
async function createAndWaitGenerationTask(options: BackendGenerationTaskOptions, prepared: PreparedGenerationReferences, dependencies: GenerationTaskDependencies) {
const task = await createBackendGenerationTask(options, prepared, dependencies);
const { signal, onTaskUpdate, onTextDelta } = options;
@@ -535,7 +544,8 @@ async function prepareBackendMediaReference(media: ReferenceVideo | ReferenceAud
}
}
async function prepareBackendImageReference(image: ReferenceImage) {
async function prepareBackendImageReference(image: ReferenceImage, preferArkAssetUrl = false) {
if (preferArkAssetUrl && image.arkAssetId) return backendImageReference(image, { url: `asset://${image.arkAssetId}` });
if (resourceIdFromStorageKey(image.storageKey)) return backendImageReference(image, { storageKey: image.storageKey });
const sourceUrl = image.url || image.dataUrl;
if (/^https?:\/\//i.test(sourceUrl)) return backendImageReference(image, { url: sourceUrl });

View File

@@ -106,7 +106,7 @@ async function buildVolcengineArkContent(prompt: string, references: ReferenceIm
const imagePlan = resolveVideoImageReferences(references.slice(0, SEEDANCE_REFERENCE_LIMITS.images), options, { videoCount: videoReferences.length, audioCount: audioReferences.length });
if (prompt.trim()) content.push({ type: "text", text: prompt.trim() });
for (const { image, role } of imagePlan) {
content.push({ type: "image_url", image_url: { url: await resolveVolcengineArkReferenceUrl(image.url || image.dataUrl, image.storageKey) }, role });
content.push({ type: "image_url", image_url: { url: await resolveVolcengineArkReferenceUrl(image.arkAssetId ? `asset://${image.arkAssetId}` : image.url || image.dataUrl, image.storageKey) }, role });
}
for (const video of videoReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.videos)) {
content.push({ type: "video_url", video_url: { url: await resolveVolcengineArkReferenceUrl(video.url, video.storageKey) }, role: "reference_video" });
@@ -118,8 +118,10 @@ async function buildVolcengineArkContent(prompt: string, references: ReferenceIm
}
async function resolveVolcengineArkReferenceUrl(value: string | undefined, storageKey?: string) {
// 已录入的方舟素材 ID 优先:已过审或被授权的素材直接引用,无需再换对象存储地址。
if (String(value || "").startsWith("asset://")) return String(value);
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
if (isPublicMediaUrl(value || "") || String(value || "").startsWith("asset://")) return String(value);
if (isPublicMediaUrl(value || "")) return String(value);
throw new Error("火山方舟视频参考素材需要公网 URL 或 asset:// 素材 ID;请先将本地素材保存到对象存储");
}

View File

@@ -44,6 +44,8 @@ type AssetBase<T extends AssetKind> = {
primaryVersionId?: string;
source?: string;
note?: string;
arkAssetId?: string;
portraitCertified?: boolean;
createdAt: string;
updatedAt: string;
metadata?: Record<string, unknown>;

View File

@@ -10,6 +10,9 @@ export type ReferenceImage = {
bytes?: number;
width?: number;
height?: number;
// 绑定素材录入的火山方舟素材 ID。方舟视频渠道生成时会优先改写为
// asset:// 引用,跳过上传同步;其他渠道与本地回退路径忽略此字段。
arkAssetId?: string;
// 参考图的来源:需要在真正生成时才落地成资源的那几类。
// 判别字段是 kind——新增一类时,canvas-node-generation 里的分派也要跟着加分支,
// 漏了会静默按普通图片处理(dataUrl 为空 → 参考图丢失)。