a4132616d8
- 删除 fallback_extension 与「未知声明原样放行 + 扩展名兜底」的归一逻辑 - 槽位白名单收敛为唯一映射表(content type ↔ 对象键扩展名),跨槽位类型返回 None - resolve_artifact_content_type 改为返回 Result:字节认不出且声明不在白名单时按 502 失败, 不再把 text/plain 之类的声明连同拼出来的 .glb 扩展名落进 OSS 与 asset_object - 错误体只回显截断到 64 字符的声明值,避免把任意长度的 provider 文本带进响应 - 重写 storage 测试:新增白名单外声明、跨槽位类型与字节认不出三类用例 - 同步技术方案:说明白名单范围与「不做任何兜底」的口径
288 lines
11 KiB
TypeScript
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)
|
|
);
|
|
}
|