新增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:
@@ -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”才 submit,submit 成功后必须先落 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 ID;provider 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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user