新增api-server Tripo3D worker执行链路与结果落库

- 新增 artifacts/result/worker 三个文件:产物读取、严格结果构造与 at-most-once submit 执行链路
- 没有 checkpoint 才 submit;submit 成功后先落 checkpoint 再轮询,checkpoint 写入失败按终态失败,不退回可重试队列
- 模型与预览图分别写入 OSS 并按 operation 稳定 ID 登记 asset_object,预览对象单独登记以便按对象键解析归属
- 落点按 target 分支写入项目资源或账号素材,imageSrc 与 thumbnailSrc 指向预览图,模型本体留在 objectKey
- 画布回填只走 placement-only;新增只依赖资源身份与尺寸的占位条目构造函数供 3D 复用
- 标准消费者的队列结果透传调用方显式给出的 result,使 3D 完成结果可从既有任务查询返回
- external_generation_worker 增加两个 job kind 分支并纳入长任务超时,用户可见失败文案收敛为稳定文本
This commit is contained in:
2026-09-21 14:36:59 +08:00
parent ac08ccbe39
commit 29e554a30c
8 changed files with 663 additions and 4 deletions
@@ -1154,6 +1154,15 @@ fn serialize_atomic_editor_generation_job_result(
compact_external_api_generation_result(response.clone()),
);
}
// 中文注释:标准消费者默认只回来源身份——编辑器结果由画布读回,不靠任务查询。
// 3D 生成没有画布读回路径,完成结果只能从任务查询拿到,因此调用方显式给出
// `result` 时逐字透传;其它标准任务不写这个键,行为不变。
if queue_result_context.consumer == EditorGenerationQueueConsumer::Standard
&& let Some(result) = response.get("result").cloned()
&& let Some(object) = payload.as_object_mut()
{
object.insert("result".to_string(), result);
}
if let Some(warning) = extract_editor_generation_warning(response)
&& let Some(object) = payload.as_object_mut()
{
@@ -11284,10 +11293,30 @@ pub(crate) fn build_editor_canvas_resource_placement_item(
completion: &EditorCanvasGenerationCompletionRequest,
resource: &EditorProjectResourcePayload,
layer_id: String,
) -> Value {
build_editor_canvas_placement_item(
completion,
resource.resource_id.as_str(),
resource.width,
resource.height,
layer_id,
)
}
/// 只依赖资源身份与像素尺寸的画布占位回填条目。
///
/// 3D 生成结果在落库前只有对象与尺寸、没有完整的资源载荷,因此把这条口径单独暴露出来,
/// 保证它与图片生成回填的是同一份条目形状。
pub(crate) fn build_editor_canvas_placement_item(
completion: &EditorCanvasGenerationCompletionRequest,
resource_id: &str,
resource_width: u32,
resource_height: u32,
layer_id: String,
) -> Value {
let placeholder = &completion.placeholder;
let original_width = positive_f64(resource.width as f64, placeholder.original_width);
let original_height = positive_f64(resource.height as f64, placeholder.original_height);
let original_width = positive_f64(resource_width as f64, placeholder.original_width);
let original_height = positive_f64(resource_height as f64, placeholder.original_height);
let width = positive_f64(placeholder.width, original_width);
let height = positive_f64(placeholder.height, original_height);
let placeholder_width = positive_f64(placeholder.width, width);
@@ -11297,7 +11326,7 @@ pub(crate) fn build_editor_canvas_resource_placement_item(
json!({
"layerId": layer_id,
"resourceId": resource.resource_id,
"resourceId": resource_id,
"title": completion.title.trim(),
"x": x,
"y": y,
@@ -23,6 +23,7 @@ use crate::editor_generation_queue::{
EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_BACKGROUND_REMOVAL_JOB_KIND,
EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND,
};
use crate::tripo3d::job::{MODEL3D_IMAGE_TO_MODEL_JOB_KIND, MODEL3D_TEXT_TO_MODEL_JOB_KIND};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
@@ -233,6 +234,12 @@ pub(crate) fn user_visible_external_generation_error(
if job_kind == EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND && error.is_some() {
return Some("背景音乐生成失败,请稍后重试。".to_string());
}
// 3D 生成失败文本会带上 provider 状态码与上游报文,只对用户暴露稳定文案。
if (job_kind == MODEL3D_TEXT_TO_MODEL_JOB_KIND || job_kind == MODEL3D_IMAGE_TO_MODEL_JOB_KIND)
&& error.is_some()
{
return Some("3D 模型生成失败,请稍后重试。".to_string());
}
error
}
@@ -57,6 +57,10 @@ use crate::{
},
request_context::RequestContext,
state::AppState,
tripo3d::{
job::{MODEL3D_IMAGE_TO_MODEL_JOB_KIND, MODEL3D_TEXT_TO_MODEL_JOB_KIND},
worker::process_model3d_job,
},
vector_engine_audio_generation::{
generate_editor_background_music_for_owner, generate_editor_sound_effect_for_owner,
},
@@ -1076,6 +1080,18 @@ async fn process_external_generation_job_once(
}
}
}
MODEL3D_TEXT_TO_MODEL_JOB_KIND | MODEL3D_IMAGE_TO_MODEL_JOB_KIND => {
// 3D 生成复用现有 worker 外壳,但内部是 at-most-once submit:崩溃后租约到期
// 不会重新 submit,失败即终态并冲正扣费。
let caller = editor_generation_worker_caller(&worker_id, &job)?;
match process_model3d_job(&state, &caller, &job, provider_deadline).await {
Ok(()) => Ok(()),
Err(message) => {
fail_job(&state, &worker_id, &job, message.clone()).await?;
Err(message)
}
}
}
unknown => {
warn!(
job_id = job.job_id,
@@ -1735,7 +1751,9 @@ fn external_generation_worker_job_timeout(config: &AppConfig, job_kind: &str) ->
| EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND
| EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND
| EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND
| EDITOR_VIDEO_GENERATION_JOB_KIND => config.external_generation_worker_long_job_timeout,
| EDITOR_VIDEO_GENERATION_JOB_KIND
| MODEL3D_TEXT_TO_MODEL_JOB_KIND
| MODEL3D_IMAGE_TO_MODEL_JOB_KIND => config.external_generation_worker_long_job_timeout,
_ => config.external_generation_worker_job_timeout,
}
}
@@ -0,0 +1,64 @@
//! provider 产物的读取。
//!
//! provider 侧提供的是流式句柄,这里按字节收口成 `Vec<u8>` 交给 OSS 写入。
//! TODO(stream): provider SDK 与 `platform-oss` 都支持流式 / 分片后,这里应当直接
//! 把流转交 OSS,不再把几十 MB 的模型完整读进 api-server 内存。
use axum::http::StatusCode;
use platform_tripo::{TripoDownloadedArtifact, TripoError};
use serde_json::json;
use crate::http_error::AppError;
use super::{errors::map_provider_error, provider::TRIPO_PROVIDER};
/// 单次 attempt 内允许读取的单个产物上限。超过它说明响应异常或产物口径变了,
/// 宁可失败退款也不要把 worker 的内存打满。
const MODEL3D_MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024;
pub(crate) struct DownloadedArtifact {
pub(crate) bytes: Vec<u8>,
pub(crate) content_type: String,
}
pub(crate) async fn read_artifact(
mut artifact: TripoDownloadedArtifact,
) -> Result<DownloadedArtifact, AppError> {
let content_type = artifact.content_type.clone().unwrap_or_default();
let mut bytes = Vec::new();
while let Some(chunk) = artifact.next_chunk().await.map_err(map_provider_error)? {
let next_len = bytes.len() as u64 + chunk.len() as u64;
if next_len > MODEL3D_MAX_ARTIFACT_BYTES {
return Err(artifact_too_large(next_len));
}
bytes.extend_from_slice(chunk.as_ref());
}
if bytes.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "model3d-artifact-empty",
"message": "provider 返回的 3D 产物为空。",
})),
);
}
Ok(DownloadedArtifact {
bytes,
content_type,
})
}
fn artifact_too_large(actual_bytes: u64) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "model3d-artifact-too-large",
"message": "provider 返回的 3D 产物超过单次尝试允许的体积上限。",
"actualBytes": actual_bytes,
"maxBytes": MODEL3D_MAX_ARTIFACT_BYTES,
}))
}
/// provider 错误在 artifacts 层与其它层使用同一份映射,避免两处口径分叉。
pub(crate) fn map_artifact_error(error: TripoError) -> AppError {
map_provider_error(error)
}
@@ -4,6 +4,7 @@
//! 区分,结果类型也按端点严格区分,不做“一个 kind 带 mode 字段”的变形。
use serde_json::Value;
use shared_contracts::model3d::common::Model3dGenerationTarget;
use shared_contracts::model3d::image_to_model::Model3dImageToModelRequest;
use shared_contracts::model3d::text_to_model::Model3dTextToModelRequest;
@@ -83,6 +84,29 @@ impl Model3dJobRequest {
}
}
pub(crate) fn target(&self) -> &Model3dGenerationTarget {
match self {
Self::TextToModel(request) => &request.target,
Self::ImageToModel(request) => &request.target,
}
}
/// 资源 / 素材的 `prompt` 审计字段。图生 3D 的 provider 参数里没有提示词,
/// 因此用固定文案而不是把请求参数复制一份出来。
pub(crate) fn audit_prompt(&self) -> String {
match self {
Self::TextToModel(request) => request.generation.prompt.clone(),
Self::ImageToModel(_) => MODEL3D_IMAGE_TO_MODEL_AUDIT_PROMPT.to_string(),
}
}
pub(crate) fn model_version(&self) -> &'static str {
match self {
Self::TextToModel(request) => request.generation.model.as_str(),
Self::ImageToModel(request) => request.generation.model.as_str(),
}
}
/// 任务审计用的来源实体:项目落点用项目 ID,素材库落点用目录 ID。
pub(crate) fn source_entity_id(&self) -> String {
match self {
@@ -92,6 +116,8 @@ impl Model3dJobRequest {
}
}
const MODEL3D_IMAGE_TO_MODEL_AUDIT_PROMPT: &str = "图生 3D 模型";
fn target_source_entity_id(
target: &shared_contracts::model3d::common::Model3dGenerationTarget,
) -> String {
@@ -8,15 +8,18 @@ use axum::Router;
use crate::state::AppState;
pub(crate) mod artifacts;
pub(crate) mod errors;
pub(crate) mod image_source;
pub(crate) mod job;
pub(crate) mod pricing;
pub(crate) mod provider;
pub(crate) mod queue;
pub(crate) mod result;
pub(crate) mod routes;
pub(crate) mod storage;
pub(crate) mod validation;
pub(crate) mod worker;
pub(crate) fn router(state: AppState) -> Router<AppState> {
routes::router(state)
@@ -0,0 +1,59 @@
//! 3D 完成结果的构造。
//!
//! 结果按端点使用严格 tagged enum,只带正式资源引用与已落地对象元数据;
//! provider task ID、带签名的临时地址与 SDK 类型都不进入结果。
use serde_json::{Value, json};
use shared_contracts::model3d::Model3dGenerationResult;
use shared_contracts::model3d::common::{Model3dGeneratedArtifact, Model3dGenerationTargetRef};
use shared_contracts::model3d::image_to_model::Model3dImageToModelResult;
use shared_contracts::model3d::text_to_model::Model3dTextToModelResult;
use super::{job::Model3dJobKind, storage::StoredModel3dArtifact};
pub(crate) fn artifact_metadata(stored: &StoredModel3dArtifact) -> Model3dGeneratedArtifact {
Model3dGeneratedArtifact {
object_key: stored.object_key.clone(),
content_type: stored.content_type.clone(),
content_length: stored.content_length,
sha256: stored.sha256.clone(),
}
}
pub(crate) fn build_result(
kind: Model3dJobKind,
target: Model3dGenerationTargetRef,
model: &StoredModel3dArtifact,
preview: &StoredModel3dArtifact,
) -> Model3dGenerationResult {
let model = artifact_metadata(model);
let preview = artifact_metadata(preview);
match kind {
Model3dJobKind::TextToModel => {
Model3dGenerationResult::TextToModel(Model3dTextToModelResult {
target,
model,
preview,
})
}
Model3dJobKind::ImageToModel => {
Model3dGenerationResult::ImageToModel(Model3dImageToModelResult {
target,
model,
preview,
})
}
}
}
/// 队列终态结果载荷:外层固定 `result` 字段,值就是严格类型化的完成结果。
pub(crate) fn job_result_payload(result: Model3dGenerationResult) -> Value {
match serde_json::to_value(result) {
Ok(value) => json!({ "result": value }),
// 结果由字符串与数字组成,序列化失败属于实现错误;仍返回可对账的稳定形状。
Err(error) => json!({
"result": null,
"resultSerializeError": error.to_string(),
}),
}
}
@@ -0,0 +1,453 @@
//! Tripo 3D 生成的 worker 执行链路。
//!
//! 与其它生成任务的关键差异是 **at-most-once submit**provider 不接受幂等重放,
//! 所以只有“没有 checkpoint 的 job”才 submitsubmit 成功后必须先落 checkpoint 再轮询;
//! 单次尝试(`max_attempts = 1`)失败即终态收口,不存在第二次 submit 的窗口,
//! checkpoint 只用于继续查询与人工对账。
use std::time::{Duration, Instant};
use axum::http::StatusCode;
use platform_tripo::{TripoProviderClient, TripoTaskHandle, TripoTaskSnapshot};
use serde_json::json;
use shared_contracts::editor_generation::{
editor_generation_stable_asset_id, editor_generation_stable_resource_id,
};
use shared_contracts::model3d::common::{
Model3dGenerationTarget, Model3dGenerationTargetRef, Model3dTaskStatus,
};
use spacetime_client::{
EditorAssetCreateRecordInput, EditorProjectResourceCreateRecordInput,
ExternalGenerationJobProviderCheckpointRecordInput, ExternalGenerationJobRecord,
editor_project::EditorGenerationResultPersistItemRecordInput,
};
use crate::{
asset_billing::execute_billable_asset_operation_with_cost,
editor_project::{
EditorCanvasLayoutPlan, EditorGenerationCaller, build_editor_canvas_placement_item,
current_utc_micros, editor_generation_job_result_payload_json,
editor_media_src_from_object_key, generated_canvas_layer_id,
persist_editor_generation_result_atomically,
},
http_error::AppError,
state::AppState,
};
use super::{
artifacts::read_artifact,
errors::map_provider_error,
image_source::resolve_image_input,
job::{MODEL3D_PROVIDER_KIND, Model3dJobRequest, parse_model3d_job_request},
provider::{TRIPO_PROVIDER, tripo_provider_client},
result::{build_result, job_result_payload},
storage::{
MODEL3D_ASSET_KIND, Model3dArtifactSlot, StoredModel3dArtifact, store_model3d_artifact,
},
};
/// 轮询间隔:provider 侧单次生成通常要几分钟,秒级轮询只会白烧查询配额。
const MODEL3D_POLL_INTERVAL: Duration = Duration::from_secs(5);
const MODEL3D_MODEL_SLOT: &str = "model";
const MODEL3D_PREVIEW_SLOT: &str = "preview";
pub(crate) async fn process_model3d_job(
state: &AppState,
caller: &EditorGenerationCaller,
job: &ExternalGenerationJobRecord,
provider_deadline: Instant,
) -> Result<(), String> {
let request =
parse_model3d_job_request(job.job_kind.as_str(), job.request_payload_json.as_str())?;
execute_billable_asset_operation_with_cost(
state,
caller.owner_user_id.as_str(),
MODEL3D_ASSET_KIND,
job.job_id.as_str(),
job.price_mud_points,
run_model3d_job(state, caller, job, request, provider_deadline),
)
.await
.map_err(|error| error.body_text())
}
async fn run_model3d_job(
state: &AppState,
caller: &EditorGenerationCaller,
job: &ExternalGenerationJobRecord,
request: Model3dJobRequest,
provider_deadline: Instant,
) -> Result<(), AppError> {
let client = tripo_provider_client(&state.config)?;
let handle = match existing_checkpoint(job) {
Some(task_id) => TripoTaskHandle { task_id },
None => submit_and_checkpoint(state, caller, job, &client, &request).await?,
};
let snapshot = poll_until_terminal(&client, &handle, provider_deadline).await?;
let model = read_artifact(
client
.download_model(&snapshot)
.await
.map_err(map_provider_error)?,
)
.await?;
let preview = read_artifact(
client
.download_rendered_image(&snapshot)
.await
.map_err(map_provider_error)?,
)
.await?;
let (preview_width, preview_height) = preview_dimensions(preview.bytes.as_slice())?;
let stored_model = store_model3d_artifact(
state,
caller,
Model3dArtifactSlot::Model,
model.bytes,
model.content_type.as_str(),
)
.await?;
let stored_preview = store_model3d_artifact(
state,
caller,
Model3dArtifactSlot::Preview,
preview.bytes,
preview.content_type.as_str(),
)
.await?;
persist_result(
state,
caller,
job,
&request,
stored_model,
stored_preview,
preview_width,
preview_height,
)
.await
}
/// checkpoint 是 provider 侧任务的唯一凭据;有值就只能续跑查询。
fn existing_checkpoint(job: &ExternalGenerationJobRecord) -> Option<String> {
job.provider_task_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
async fn submit_and_checkpoint(
state: &AppState,
caller: &EditorGenerationCaller,
job: &ExternalGenerationJobRecord,
client: &TripoProviderClient,
request: &Model3dJobRequest,
) -> Result<TripoTaskHandle, AppError> {
let handle = match request {
Model3dJobRequest::TextToModel(request) => client.submit(&request.generation).await,
Model3dJobRequest::ImageToModel(request) => {
let input = resolve_image_input(
state,
client,
caller.owner_user_id.as_str(),
&request.source,
)
.await?;
client
.submit_image_to_model(&input, &request.generation)
.await
}
}
.map_err(map_provider_error)?;
// submit 已经消耗了 provider 额度。checkpoint 写不进去时不能再退回可重试队列,
// 否则下一次 claim 会再次 submit;这里直接失败并保留“provider 已消耗”的对账信息。
state
.spacetime_client()
.set_external_generation_job_provider_checkpoint(
ExternalGenerationJobProviderCheckpointRecordInput {
job_id: job.job_id.clone(),
worker_id: worker_id(caller)?.to_string(),
lease_token: lease_token(job)?.to_string(),
provider_kind: MODEL3D_PROVIDER_KIND.to_string(),
provider_task_id: handle.task_id.clone(),
},
)
.await
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "model3d-checkpoint-write-failed",
"message": format!(
"3D 生成任务已提交但 checkpoint 写入失败,本次尝试终态失败,等待人工对账:{error}"
),
}))
})?;
caller.report_processing_phase(state).await?;
Ok(handle)
}
async fn poll_until_terminal(
client: &TripoProviderClient,
handle: &TripoTaskHandle,
provider_deadline: Instant,
) -> Result<TripoTaskSnapshot, AppError> {
loop {
if Instant::now() >= provider_deadline {
return Err(provider_deadline_error());
}
let snapshot = client.get_task(handle).await.map_err(map_provider_error)?;
match snapshot.status {
Model3dTaskStatus::Completed => return Ok(snapshot),
Model3dTaskStatus::Queued | Model3dTaskStatus::Running => {}
Model3dTaskStatus::Failed
| Model3dTaskStatus::Cancelled
| Model3dTaskStatus::Expired => return Err(task_not_completed(&snapshot)),
}
let remaining = provider_deadline.saturating_duration_since(Instant::now());
tokio::time::sleep(MODEL3D_POLL_INTERVAL.min(remaining)).await;
}
}
#[allow(clippy::too_many_arguments)]
async fn persist_result(
state: &AppState,
caller: &EditorGenerationCaller,
job: &ExternalGenerationJobRecord,
request: &Model3dJobRequest,
model: StoredModel3dArtifact,
preview: StoredModel3dArtifact,
preview_width: u32,
preview_height: u32,
) -> Result<(), AppError> {
let operation = caller.operation.as_ref().ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": TRIPO_PROVIDER,
"message": "落库 3D 生成结果时缺少稳定 operation 上下文。",
}))
})?;
let owner_user_id = caller.owner_user_id.as_str();
// 3D 资源在画布与素材库里按预览图渲染,模型本体留在 objectKey,因此两者互不覆盖。
let preview_src = editor_media_src_from_object_key(preview.object_key.as_str());
let completed_at_micros = current_utc_micros();
let audit_prompt = request.audit_prompt();
let model_version = request.model_version();
let (project_id, folder_id, label, canvas_completion) = match request.target() {
Model3dGenerationTarget::ProjectResource {
project_id,
canvas_completion,
} => (
Some(project_id.clone()),
None,
MODEL3D_DEFAULT_ASSET_LABEL.to_string(),
canvas_completion.clone(),
),
Model3dGenerationTarget::AssetLibrary { folder_id, label } => {
(None, Some(folder_id.clone()), label.clone(), None)
}
};
let canvas_project_id = project_id.clone();
let resource_id = editor_generation_stable_resource_id(
owner_user_id,
operation.operation_kind.as_str(),
job.job_id.as_str(),
MODEL3D_MODEL_SLOT,
);
let asset_id = editor_generation_stable_asset_id(
owner_user_id,
operation.operation_kind.as_str(),
job.job_id.as_str(),
MODEL3D_MODEL_SLOT,
);
let has_resource = project_id.is_some();
let project_resource = project_id.map(|project_id| EditorProjectResourceCreateRecordInput {
resource_id: resource_id.clone(),
project_id,
owner_user_id: owner_user_id.to_string(),
asset_object_id: Some(model.asset_object.asset_object_id.clone()),
image_src: preview_src.clone(),
object_key: Some(model.object_key.clone()),
width: preview_width,
height: preview_height,
source_type: "generated".to_string(),
prompt: Some(audit_prompt.clone()),
actual_prompt: None,
model: Some(model_version.to_string()),
provider: Some(MODEL3D_PROVIDER_KIND.to_string()),
// 平台自己的 operation IDprovider task ID 只留在 checkpoint,不落业务行。
task_id: Some(job.job_id.clone()),
source_resource_id: None,
asset_kind: Some(MODEL3D_ASSET_KIND.to_string()),
generation_inputs_json: None,
updated_at_micros: completed_at_micros,
image_sequence_frames_json: None,
image_sequence_duration_ms: None,
});
let asset_id_for_result = asset_id.clone();
let asset = folder_id.map(|folder_id| EditorAssetCreateRecordInput {
asset_id,
owner_user_id: owner_user_id.to_string(),
folder_id,
label,
asset_object_id: Some(model.asset_object.asset_object_id.clone()),
image_src: preview_src.clone(),
object_key: Some(model.object_key.clone()),
width: preview_width,
height: preview_height,
source_type: "generated".to_string(),
prompt: Some(audit_prompt),
actual_prompt: None,
model: Some(model_version.to_string()),
provider: Some(MODEL3D_PROVIDER_KIND.to_string()),
task_id: Some(job.job_id.clone()),
asset_kind: Some(MODEL3D_ASSET_KIND.to_string()),
generation_inputs_json: None,
source_resource_id: has_resource.then(|| resource_id.clone()),
generation_cost_mud_points: job.price_mud_points,
now_micros: completed_at_micros,
thumbnail_src: Some(preview_src.clone()),
group_task_id: None,
group_task_expected_asset_count: None,
image_sequence_frames_json: None,
image_sequence_duration_ms: None,
});
// 结果先构造:它引用的是对象元数据,与随后要搬进 item 的 asset_object 是两份值。
let result = build_result(
request.job_kind(),
target_ref(request.target(), resource_id.clone(), asset_id_for_result),
&model,
&preview,
);
// 预览图单独登记成 asset_object:它没有自己的资源行,但必须属于当前账号,
// 否则后续按 objectKey 解析归属时会认为它未登记。
let items = vec![
EditorGenerationResultPersistItemRecordInput {
slot: MODEL3D_MODEL_SLOT.to_string(),
asset_object: Some(model.asset_object),
project_resource,
asset,
binding: None,
},
EditorGenerationResultPersistItemRecordInput {
slot: MODEL3D_PREVIEW_SLOT.to_string(),
asset_object: Some(preview.asset_object),
project_resource: None,
asset: None,
binding: None,
},
];
let canvas_layout_plan = match canvas_completion.as_ref() {
Some(completion) if has_resource => {
let layer_id = generated_canvas_layer_id(resource_id.as_str());
EditorCanvasLayoutPlan::generation(
owner_user_id,
canvas_project_id.as_deref(),
Some(completion),
vec![build_editor_canvas_placement_item(
completion,
resource_id.as_str(),
preview_width,
preview_height,
layer_id.clone(),
)],
Some(layer_id),
)
}
_ => EditorCanvasLayoutPlan::None,
};
let job_result_payload_json =
editor_generation_job_result_payload_json(caller, job_result_payload(result));
persist_editor_generation_result_atomically(
state,
caller,
items,
canvas_layout_plan,
job_result_payload_json,
completed_at_micros,
)
.await?;
Ok(())
}
fn target_ref(
target: &Model3dGenerationTarget,
resource_id: String,
asset_id: String,
) -> Model3dGenerationTargetRef {
match target {
Model3dGenerationTarget::ProjectResource { .. } => {
Model3dGenerationTargetRef::ProjectResource { resource_id }
}
Model3dGenerationTarget::AssetLibrary { .. } => {
Model3dGenerationTargetRef::AssetLibrary { asset_id }
}
}
}
fn preview_dimensions(bytes: &[u8]) -> Result<(u32, u32), AppError> {
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(|error| output_schema_error(format!("预览图格式无法识别:{error}")))?;
let (width, height) = reader
.into_dimensions()
.map_err(|error| output_schema_error(format!("预览图尺寸无法读取:{error}")))?;
if width == 0 || height == 0 {
return Err(output_schema_error("预览图尺寸为零".to_string()));
}
Ok((width, height))
}
const MODEL3D_DEFAULT_ASSET_LABEL: &str = "3D 模型";
fn worker_id(caller: &EditorGenerationCaller) -> Result<&str, AppError> {
caller
.operation
.as_ref()
.and_then(|operation| operation.worker_id.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| missing_operation_context("worker_id"))
}
fn lease_token(job: &ExternalGenerationJobRecord) -> Result<&str, AppError> {
job.lease_token
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| missing_operation_context("lease_token"))
}
fn missing_operation_context(field: &str) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": TRIPO_PROVIDER,
"message": format!("3D 生成任务缺少 {field},无法写入 provider checkpoint。"),
}))
}
fn provider_deadline_error() -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "tripo-provider-timeout",
"message": "3D 生成在本次 worker 预算内没有完成,本次尝试终态失败。",
}))
}
fn task_not_completed(snapshot: &TripoTaskSnapshot) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "tripo-task-failed",
"message": "3D 生成任务未成功完成。",
"taskStatus": snapshot.status,
"providerCode": snapshot.failure.as_ref().and_then(|failure| failure.code),
"providerMessage": snapshot.failure.as_ref().and_then(|failure| failure.message.clone()),
}))
}
fn output_schema_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": TRIPO_PROVIDER,
"reason": "tripo-output-schema",
"message": message,
}))
}