feat(assets): 素材库 - 方舟素材 ID 引用与人像认证标记
- 素材编辑弹窗新增「方舟素材 ID」(asset- 前缀校验)与「人像认证」开关, 卡片封面显示人像认证徽章,详情抽屉展示方舟素材 ID - 画布图片节点绑定的素材录入方舟素材 ID 后,方舟视频渠道(volcengine-ark-video 或 Ark Plan BaseURL)生成时参考图直接提交 asset:// 引用,跳过可信素材上传同步; 浏览器直连路径同样优先 asset://;非方舟渠道与本地回退路径不受影响 - 素材字段随 payload_json 透传,AssetBase 与 parseAssetRecord 白名单同步扩展
This commit is contained in:
@@ -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:// 引用、跳过「同步方舟可信素材」;非方舟渠道生成不受影响,仍走原参考图路径。
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;请先将本地素材保存到对象存储");
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -10,6 +10,9 @@ export type ReferenceImage = {
|
||||
bytes?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
// 绑定素材录入的火山方舟素材 ID。方舟视频渠道生成时会优先改写为
|
||||
// asset:// 引用,跳过上传同步;其他渠道与本地回退路径忽略此字段。
|
||||
arkAssetId?: string;
|
||||
// 参考图的来源:需要在真正生成时才落地成资源的那几类。
|
||||
// 判别字段是 kind——新增一类时,canvas-node-generation 里的分派也要跟着加分支,
|
||||
// 漏了会静默按普通图片处理(dataUrl 为空 → 参考图丢失)。
|
||||
|
||||
Reference in New Issue
Block a user