diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index ee78451b3..02a3e3d99 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index 76b18a708..bbbdd27d0 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -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 } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index c2fa99485..2102336b0 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -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, } } diff --git a/server-rs/crates/api-server/src/tripo3d/artifacts.rs b/server-rs/crates/api-server/src/tripo3d/artifacts.rs new file mode 100644 index 000000000..52533d359 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/artifacts.rs @@ -0,0 +1,64 @@ +//! provider 产物的读取。 +//! +//! provider 侧提供的是流式句柄,这里按字节收口成 `Vec` 交给 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, + pub(crate) content_type: String, +} + +pub(crate) async fn read_artifact( + mut artifact: TripoDownloadedArtifact, +) -> Result { + 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) +} diff --git a/server-rs/crates/api-server/src/tripo3d/job.rs b/server-rs/crates/api-server/src/tripo3d/job.rs index 39e48b6f4..058de8e92 100644 --- a/server-rs/crates/api-server/src/tripo3d/job.rs +++ b/server-rs/crates/api-server/src/tripo3d/job.rs @@ -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 { diff --git a/server-rs/crates/api-server/src/tripo3d/mod.rs b/server-rs/crates/api-server/src/tripo3d/mod.rs index 68459b5e1..34bd988c7 100644 --- a/server-rs/crates/api-server/src/tripo3d/mod.rs +++ b/server-rs/crates/api-server/src/tripo3d/mod.rs @@ -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 { routes::router(state) diff --git a/server-rs/crates/api-server/src/tripo3d/result.rs b/server-rs/crates/api-server/src/tripo3d/result.rs new file mode 100644 index 000000000..c93f1b754 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/result.rs @@ -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(), + }), + } +} diff --git a/server-rs/crates/api-server/src/tripo3d/worker.rs b/server-rs/crates/api-server/src/tripo3d/worker.rs new file mode 100644 index 000000000..a9b37ec44 --- /dev/null +++ b/server-rs/crates/api-server/src/tripo3d/worker.rs @@ -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 { + 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 { + 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 { + 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, + })) +}