diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 23e4dad20..8dd1e3653 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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://` 跳过上传同步(后端任务与浏览器直连路径一致);任务重跑按存储引用还原时会回退到常规上传路径。后端 go test(排除 main 存量失败用例 TestUserAssetsBatchIsBoundedAndScoped 与 protocol 插件清单校验)与前端构建通过,需浏览器验收: + +- [ ] 管理页断开网络或把 Region 暂改为 cn-shanghai 后在画布上传方舟素材失败,恢复后再次上传直接发起新请求成功,无需清理数据库 binding 记录。 +- [ ] 套餐未开通等上游 403 场景,上传失败提示包含方舟原始错误码(如 SubscriptionRequired),不再是「模型服务鉴权失败」。 +- [ ] 素材库编辑图片素材:可填写 asset- 开头的方舟素材 ID、勾选人像认证,保存后刷新页面两字段仍在;卡片封面出现「人像认证」徽章,详情抽屉显示方舟素材 ID 标签;非法 ID(不以 asset- 开头)被表单拦截。 +- [ ] 画布图片节点绑定已录入方舟素材 ID 的素材后连接视频节点,用方舟渠道生成:任务参考图为 asset:// 引用、跳过「同步方舟可信素材」;非方舟渠道生成不受影响,仍走原参考图路径。 diff --git a/web/src/components/canvas/canvas-node-generation.ts b/web/src/components/canvas/canvas-node-generation.ts index 50ee6883b..47e1c46d0 100644 --- a/web/src/components/canvas/canvas-node-generation.ts +++ b/web/src/components/canvas/canvas-node-generation.ts @@ -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) => { diff --git a/web/src/lib/asset-record.ts b/web/src/lib/asset-record.ts index 0d170e141..9d46bfa18 100644 --- a/web/src/lib/asset-record.ts +++ b/web/src/lib/asset-record.ts @@ -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 } : {}), }; diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index d24073f69..ef8e563d4 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -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() { + + + + +
@@ -1142,6 +1157,7 @@ function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuIt {kind ? assetKindLabel(kind) : "素材"} {isTrash ? 回收站 : {assetCategoryLabel(asset.category)}} + {asset.portraitCertified ? 人像认证 : null} {clock ? {clock} : null} 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} ))} + {asset.arkAssetId ? ( + + 方舟 {asset.arkAssetId} + + ) : null}
diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index c4fa67da1..093b18bf7 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -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): Promise { - const preparedImages = await Promise.all(referenceImages.map(prepareBackendImageReference)); +}: Pick): Promise { + 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 }); diff --git a/web/src/services/api/video-provider-seedance.ts b/web/src/services/api/video-provider-seedance.ts index 12e84b9e0..abf255c62 100644 --- a/web/src/services/api/video-provider-seedance.ts +++ b/web/src/services/api/video-provider-seedance.ts @@ -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;请先将本地素材保存到对象存储"); } diff --git a/web/src/stores/use-asset-store.ts b/web/src/stores/use-asset-store.ts index f91a335e1..46236ab85 100644 --- a/web/src/stores/use-asset-store.ts +++ b/web/src/stores/use-asset-store.ts @@ -44,6 +44,8 @@ type AssetBase = { primaryVersionId?: string; source?: string; note?: string; + arkAssetId?: string; + portraitCertified?: boolean; createdAt: string; updatedAt: string; metadata?: Record; diff --git a/web/src/types/image.ts b/web/src/types/image.ts index 7b4affccf..72583bea2 100644 --- a/web/src/types/image.ts +++ b/web/src/types/image.ts @@ -10,6 +10,9 @@ export type ReferenceImage = { bytes?: number; width?: number; height?: number; + // 绑定素材录入的火山方舟素材 ID。方舟视频渠道生成时会优先改写为 + // asset:// 引用,跳过上传同步;其他渠道与本地回退路径忽略此字段。 + arkAssetId?: string; // 参考图的来源:需要在真正生成时才落地成资源的那几类。 // 判别字段是 kind——新增一类时,canvas-node-generation 里的分派也要跟着加分支, // 漏了会静默按普通图片处理(dataUrl 为空 → 参考图丢失)。