Files
Genarrative/src/components/image-editor/model3d-generation/Model3dGenerationSubmission.ts
T
k88936 a4132616d8 3D 产物 content type 只认字节或槽位白名单,去掉兜底
- 删除 fallback_extension 与「未知声明原样放行 + 扩展名兜底」的归一逻辑
- 槽位白名单收敛为唯一映射表(content type ↔ 对象键扩展名),跨槽位类型返回 None
- resolve_artifact_content_type 改为返回 Result:字节认不出且声明不在白名单时按 502 失败,
  不再把 text/plain 之类的声明连同拼出来的 .glb 扩展名落进 OSS 与 asset_object
- 错误体只回显截断到 64 字符的声明值,避免把任意长度的 provider 文本带进响应
- 重写 storage 测试:新增白名单外声明、跨槽位类型与字节认不出三类用例
- 同步技术方案:说明白名单范围与「不做任何兜底」的口径
2026-09-23 16:39:15 +08:00

288 lines
11 KiB
TypeScript

import type { ExternalGenerationJobStatusRecord } from '../../../../packages/shared/src/contracts/externalGeneration';
import type {
Model3dGenerationSource,
Model3dImageToModelRequest,
Model3dTextToModelRequest,
} from '../../../../packages/shared/src/contracts/model3d';
import { isAbortError, isTimeoutError } from '../../../services/apiClient';
import type {
EditorCanvasGenerationCompletionInput,
Model3dGenerationSubmissionResponse,
Model3dPricingEndpoint,
} from '../../../services/image-editor/editorProjectClient';
import type { Model3dPricingConfig } from '../../../services/image-editor/editorProjectClient';
import type { GenerateDialogState } from '../ImageCanvasEditorTypes';
import {
DEFAULT_MODEL3D_MODEL_VERSION,
isModel3dGenerationMode,
mintModel3dAttemptNonce,
MODEL3D_MODE_UNSUPPORTED_REASON,
resolveModel3dEndpoint,
resolveModel3dGenerationParams,
resolveModel3dGenerationQuote,
} from './Model3dGenerationFormModel';
export const MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE = '请填写 3D 模型描述';
export const MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE =
'请先选择一张画布图片或素材库图片';
export const MODEL3D_TARGET_REQUIRED_MESSAGE =
'请先保存当前画布工程或打开素材库,再生成 3D 模型';
export const MODEL3D_GENERATION_FAILURE_MESSAGE =
'生成 3D 模型失败,请稍后重试。';
export const MODEL3D_GENERATION_TIMEOUT_MESSAGE =
'3D 生成请求超时,请稍后重试。';
export const MODEL3D_SUBMISSION_MISSING_QUEUE_STATE_MESSAGE =
'3D 生成任务已提交,但服务端没有返回任务状态,请稍后重试。';
export type Model3dSubmissionPlan = {
endpoint: Model3dPricingEndpoint;
/**
* 本次提交使用的尝试代次;调用方必须把它写回 dialog 状态。
*
* 老快照恢复出来的 dialog 可能没有代次,这里兜底铸一个;如果调用方不写回,
* 每次提交都会换键,双击去重与重复计费保护就静默失效。
*/
attemptNonce: string;
requestKey: string;
title: string;
/** 提交体就是端点契约本身:形状错误在编译期暴露,而不是等服务端拒绝。 */
body: Model3dTextToModelRequest | Model3dImageToModelRequest;
};
export function resolveModel3dResultTitle(dialog: GenerateDialogState): string {
const prompt = dialog.prompt.trim();
if (dialog.mode === 'model3d-text-to-model' && prompt) {
return prompt.slice(0, 24);
}
return '3D 模型';
}
/**
* 图生 3D 只接受站内的画布资源或素材库资产:本地上传必须先经既有上传登记链路
* 拿到 resourceId / assetId,才可能构成一个合法 `source`。
*/
export function resolveModel3dImageSource(
dialog: GenerateDialogState,
): Model3dGenerationSource | null {
const reference = dialog.generationReferences?.[0];
if (!reference) {
return null;
}
const resourceId = reference.resourceId?.trim();
if (resourceId) {
return { kind: 'resource', resourceId };
}
const assetId = reference.sourceAssetId?.trim();
if (assetId) {
return { kind: 'asset', assetId };
}
return null;
}
export function buildModel3dSubmissionPlan({
dialog,
projectId,
canvasCompletion,
assetFolderId,
assetLabel,
pricing,
}: {
dialog: GenerateDialogState;
projectId?: string | null;
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
/**
* 素材库落点:与其它画布生成工具一样,画布链路会把当前素材夹一起发出去,
* 结果因此同时落画布与素材库;没有画布工程时它是唯一落点。缺省或空串表示本次不落素材库。
*/
assetFolderId?: string | null;
/** 素材库里的展示名;缺省用本次结果的标题。 */
assetLabel?: string | null;
pricing?: Model3dPricingConfig | null;
}): { ok: true; plan: Model3dSubmissionPlan } | { ok: false; message: string } {
if (!isModel3dGenerationMode(dialog.mode)) {
return { ok: false, message: MODEL3D_MODE_UNSUPPORTED_REASON };
}
// 与其它画布生成工具同一条门禁:落点至少给一个(画布工程或素材夹)。
// 没有画布工程时仍然可以生成,结果只落素材库 —— 3D 不再要求「必须先保存工程」。
const trimmedProjectId = projectId?.trim();
const trimmedAssetFolderId = assetFolderId?.trim();
if (!trimmedProjectId && !trimmedAssetFolderId) {
return { ok: false, message: MODEL3D_TARGET_REQUIRED_MESSAGE };
}
const quote = resolveModel3dGenerationQuote(dialog, pricing);
if (!quote.ok) {
return { ok: false, message: quote.reason };
}
const paramsResult = resolveModel3dGenerationParams({
tier: dialog.model3dTier ?? 'textured-standard',
quad: dialog.model3dQuad ?? false,
smartLowPoly: dialog.model3dSmartLowPoly ?? false,
generateParts: dialog.model3dGenerateParts ?? false,
});
if (!paramsResult.ok) {
return { ok: false, message: paramsResult.reason };
}
const params = paramsResult.params;
const endpoint = resolveModel3dEndpoint(dialog.mode);
const attemptNonce = resolveModel3dAttemptNonce(dialog.model3dAttemptNonce);
// 价格相关参数永远全量给出:provider 的隐式默认值会直接改变价格,
// 唯一例外是 texture=false 档位不带 textureQuality(后端显式拒绝这个组合)。
const generation = {
model: DEFAULT_MODEL3D_MODEL_VERSION,
texture: params.texture,
pbr: params.pbr,
...(params.textureQuality ? { textureQuality: params.textureQuality } : {}),
geometryQuality: params.geometryQuality,
quad: params.quad,
smartLowPoly: params.smartLowPoly,
generateParts: params.generateParts,
};
// 落点与其它生成接口同形:平坦的可选字段,不再包一层 tagged enum。
// 服务端按「至少一个落点」校验,项目资源与素材库可以同时给 —— 其它画布生成工具
// 就是「projectId + assetFolderId + assetLabel」一起发,结果两边都落;3D 跟随同一口径,
// 否则同一张画布生成出来的东西,只有 3D 不会出现在素材库。
const placement = {
...(trimmedProjectId ? { projectId: trimmedProjectId } : {}),
...(trimmedAssetFolderId
? {
assetFolderId: trimmedAssetFolderId,
assetLabel: assetLabel?.trim() || resolveModel3dResultTitle(dialog),
}
: {}),
// 画布占位框是项目资源落点的回填载荷:没有工程就没有画布可以回填。
...(trimmedProjectId && canvasCompletion ? { canvasCompletion } : {}),
};
const finish = (
body: Model3dTextToModelRequest | Model3dImageToModelRequest,
): { ok: true; plan: Model3dSubmissionPlan } => ({
ok: true,
plan: {
endpoint,
attemptNonce,
requestKey: resolveModel3dRequestKey({
attemptNonce,
endpoint,
body,
}),
title: resolveModel3dResultTitle(dialog),
body,
},
});
if (dialog.mode === 'model3d-image-to-model') {
const source = resolveModel3dImageSource(dialog);
if (!source) {
return { ok: false, message: MODEL3D_IMAGE_SOURCE_REQUIRED_MESSAGE };
}
return finish({ source, generation, ...placement });
}
const prompt = dialog.prompt.trim();
if (!prompt) {
return { ok: false, message: MODEL3D_TEXT_PROMPT_REQUIRED_MESSAGE };
}
return finish({ generation: { ...generation, prompt }, ...placement });
}
/**
* 幂等键 = 尝试代次 + 请求内容指纹。
*
* 服务端按「键 + payload 指纹」dedupe:同键同请求返回原 operation,同键不同请求 409。
* 因此键必须跟着内容走,而会改内容的入口散落在好几处通用函数里(换参考图、换上传图、
* 换项目素材都发生在 `ImageCanvasGenerationDialogModel` / `ImageCanvasUploadModel`)。
* 把内容指纹并进键里,这些入口就不需要各自记得重铸键:内容一变,键必变;
* 而同一份内容重复提交(双击)仍会命中同一个 operation。
*/
export function resolveModel3dRequestKey({
attemptNonce,
endpoint,
body,
}: {
attemptNonce?: string | null;
endpoint: Model3dPricingEndpoint;
body: Model3dTextToModelRequest | Model3dImageToModelRequest;
}): string {
const nonce = resolveModel3dAttemptNonce(attemptNonce);
return `${nonce}-${fingerprintModel3dRequest(endpoint, body)}`;
}
/** 取本次提交使用的代次:已有代次直接用,缺失(例如老快照)才现场铸造。 */
export function resolveModel3dAttemptNonce(
attemptNonce?: string | null,
): string {
return attemptNonce?.trim() || mintModel3dAttemptNonce();
}
/** 键排序只求稳定,不承载任何业务语义:同一份内容必须算出同一个键。 */
function compareModel3dRequestEntryKeys(
[left]: [string, unknown],
[right]: [string, unknown],
) {
if (left < right) {
return -1;
}
return left > right ? 1 : 0;
}
function canonicalizeModel3dRequestValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalizeModel3dRequestValue);
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.sort(compareModel3dRequestEntryKeys)
.map(([key, entry]) => [key, canonicalizeModel3dRequestValue(entry)]),
);
}
return value;
}
/** FNV-1a 64 位,只用来给请求内容定址,不承担任何安全语义。 */
function fingerprintModel3dRequest(
endpoint: Model3dPricingEndpoint,
body: Record<string, unknown>,
) {
const text = `${endpoint}\n${JSON.stringify(
canonicalizeModel3dRequestValue(body),
)}`;
let hash = 0xcbf29ce484222325n;
for (let index = 0; index < text.length; index += 1) {
hash ^= BigInt(text.charCodeAt(index));
hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn;
}
return hash.toString(16).padStart(16, '0');
}
export function model3dQueueStateFromResponse(
response: Model3dGenerationSubmissionResponse | null | undefined,
): ExternalGenerationJobStatusRecord | null {
return response?.status ?? null;
}
export function resolveModel3dGenerationErrorMessage(error: unknown): string {
if (isTimeoutError(error)) {
return MODEL3D_GENERATION_TIMEOUT_MESSAGE;
}
// 传输层失败的 message 是英文技术文案(`Failed to fetch` / `The operation was
// aborted.`),不能直接当用户提示;只有服务端给出的 message 才是面向用户的。
if (isAbortError(error) || isFetchFailureError(error)) {
return MODEL3D_GENERATION_FAILURE_MESSAGE;
}
if (error instanceof Error && error.message.trim()) {
return error.message.trim();
}
if (typeof error === 'string' && error.trim()) {
return error.trim();
}
return MODEL3D_GENERATION_FAILURE_MESSAGE;
}
/** fetch 的网络层失败统一是 `TypeError: Failed to fetch`。 */
function isFetchFailureError(error: unknown) {
return (
error instanceof TypeError && /failed to fetch|network/i.test(error.message)
);
}