Merge pull request 'feat: record external generation runs' (#49) from codex/external-generation-run-audit into master
Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
@@ -159,7 +159,7 @@ npm run check:server-rs-ddd
|
||||
## 外部服务与资产
|
||||
|
||||
- LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。
|
||||
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
|
||||
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。
|
||||
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
|
||||
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
|
||||
- Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。
|
||||
@@ -168,6 +168,7 @@ npm run check:server-rs-ddd
|
||||
- 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。
|
||||
- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。
|
||||
- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。
|
||||
- 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。
|
||||
|
||||
## SpacetimeDB 表目录
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::tracking::record_external_generation_run_after_success;
|
||||
|
||||
struct BigFishDashScopeSettings {
|
||||
base_url: String,
|
||||
@@ -39,52 +40,99 @@ pub(super) async fn generate_big_fish_formal_asset(
|
||||
motion_key: Option<&str>,
|
||||
generated_at_micros: i64,
|
||||
) -> Result<String, AppError> {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)?;
|
||||
let draft = session.draft.as_ref().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "big-fish",
|
||||
"message": "玩法草稿尚未编译,不能生成正式图片。",
|
||||
}))
|
||||
})?;
|
||||
let context = build_big_fish_formal_asset_context(
|
||||
&session,
|
||||
draft,
|
||||
asset_kind,
|
||||
level,
|
||||
motion_key,
|
||||
generated_at_micros,
|
||||
)?;
|
||||
let settings = require_big_fish_dashscope_settings(state)?;
|
||||
let http_client = build_big_fish_dashscope_http_client(&settings)?;
|
||||
let generated = create_big_fish_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
context.prompt.as_str(),
|
||||
context.negative_prompt.as_str(),
|
||||
context.size.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let downloaded = download_big_fish_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载 Big Fish 正式图片失败",
|
||||
context.apply_transparent_background_post_process,
|
||||
)
|
||||
.await?;
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"assetKind": asset_kind,
|
||||
"level": level,
|
||||
"motionKey": motion_key,
|
||||
"sessionId": session_id,
|
||||
"ownerUserId": owner_user_id,
|
||||
});
|
||||
let outcome = async {
|
||||
let session = state
|
||||
.spacetime_client()
|
||||
.get_big_fish_session(session_id.to_string(), owner_user_id.to_string())
|
||||
.await
|
||||
.map_err(map_big_fish_client_error)?;
|
||||
let draft = session.draft.as_ref().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "big-fish",
|
||||
"message": "玩法草稿尚未编译,不能生成正式图片。",
|
||||
}))
|
||||
})?;
|
||||
let context = build_big_fish_formal_asset_context(
|
||||
&session,
|
||||
draft,
|
||||
asset_kind,
|
||||
level,
|
||||
motion_key,
|
||||
generated_at_micros,
|
||||
)?;
|
||||
let settings = require_big_fish_dashscope_settings(state)?;
|
||||
let http_client = build_big_fish_dashscope_http_client(&settings)?;
|
||||
let generated = create_big_fish_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
context.prompt.as_str(),
|
||||
context.negative_prompt.as_str(),
|
||||
context.size.as_str(),
|
||||
)
|
||||
.await?;
|
||||
let downloaded = download_big_fish_remote_image(
|
||||
&http_client,
|
||||
generated.image_url.as_str(),
|
||||
"下载 Big Fish 正式图片失败",
|
||||
context.apply_transparent_background_post_process,
|
||||
)
|
||||
.await?;
|
||||
|
||||
persist_big_fish_formal_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
&context,
|
||||
generated,
|
||||
downloaded,
|
||||
generated_at_micros,
|
||||
)
|
||||
.await
|
||||
persist_big_fish_formal_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
&context,
|
||||
generated,
|
||||
downloaded,
|
||||
generated_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(value) => {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
"dashscope",
|
||||
"big_fish_text_to_image",
|
||||
"大鱼正式图片生成",
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"legacyPublicPath": value.clone(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
"dashscope",
|
||||
"big_fish_text_to_image",
|
||||
"大鱼正式图片生成",
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
false,
|
||||
Some(error.to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_big_fish_formal_asset_context(
|
||||
@@ -626,6 +674,10 @@ fn map_big_fish_asset_binding_prepare_error(error: AssetObjectFieldError) -> App
|
||||
}))
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
(time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
|
||||
}
|
||||
|
||||
fn map_big_fish_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
|
||||
@@ -8,6 +8,7 @@ use platform_image::{
|
||||
vector_engine_images_generation_url,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
external_api_audit::{
|
||||
@@ -16,6 +17,7 @@ use crate::{
|
||||
},
|
||||
http_error::AppError,
|
||||
state::AppState,
|
||||
tracking::record_external_generation_run_after_success,
|
||||
};
|
||||
|
||||
pub(crate) use platform_image::GPT_IMAGE_2_MODEL;
|
||||
@@ -105,6 +107,14 @@ pub(crate) async fn create_openai_image_generation(
|
||||
reference_images: &[String],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"size": size,
|
||||
"candidateCount": candidate_count,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_image_generation(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
@@ -116,7 +126,15 @@ pub(crate) async fn create_openai_image_generation(
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(settings, result).await
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_generation",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit(
|
||||
@@ -128,6 +146,13 @@ pub(crate) async fn create_openai_image_edit(
|
||||
reference_image: &OpenAiReferenceImage,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"size": size,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": 1,
|
||||
});
|
||||
let result = create_vector_engine_image_edit(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
@@ -138,7 +163,15 @@ pub(crate) async fn create_openai_image_edit(
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(settings, result).await
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_edit",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit_with_references(
|
||||
@@ -151,6 +184,14 @@ pub(crate) async fn create_openai_image_edit_with_references(
|
||||
reference_images: &[OpenAiReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"size": size,
|
||||
"candidateCount": candidate_count,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_image_edit_with_references(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
@@ -162,7 +203,15 @@ pub(crate) async fn create_openai_image_edit_with_references(
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(settings, result).await
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_edit_with_references",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_remote_image(
|
||||
@@ -200,19 +249,57 @@ impl OpenAiImageSettings {
|
||||
}
|
||||
}
|
||||
|
||||
async fn map_platform_image_result<T>(
|
||||
async fn map_platform_image_result(
|
||||
settings: &OpenAiImageSettings,
|
||||
result: Result<T, PlatformImageError>,
|
||||
) -> Result<T, AppError> {
|
||||
result: Result<OpenAiGeneratedImages, PlatformImageError>,
|
||||
operation: &'static str,
|
||||
failure_context: &str,
|
||||
request_payload: Value,
|
||||
started_at_micros: i64,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Ok(value) => {
|
||||
if let Some(state) = settings.external_api_audit_state.as_ref() {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
VECTOR_ENGINE_PROVIDER,
|
||||
operation,
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
true,
|
||||
None,
|
||||
Some(value.task_id.clone()),
|
||||
Some(json!({
|
||||
"imageCount": value.images.len(),
|
||||
"actualPromptChars": value.actual_prompt.as_ref().map(|prompt| prompt.chars().count()),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => {
|
||||
if let Some(state) = settings.external_api_audit_state.as_ref() {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
VECTOR_ENGINE_PROVIDER,
|
||||
operation,
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
false,
|
||||
Some(error.message().to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
record_openai_image_failure_if_configured(settings, &error).await;
|
||||
Err(map_platform_image_error(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn record_openai_image_failure_if_configured(
|
||||
settings: &OpenAiImageSettings,
|
||||
error: &PlatformImageError,
|
||||
@@ -457,3 +544,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ use spacetime_client::{
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
|
||||
PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
|
||||
@@ -53,6 +53,55 @@ struct RouteTrackingSpec {
|
||||
scope_id: &'static str,
|
||||
}
|
||||
|
||||
pub async fn record_external_generation_run_after_success(
|
||||
state: &AppState,
|
||||
provider: &str,
|
||||
operation: &str,
|
||||
request_label: &str,
|
||||
request_payload: Value,
|
||||
started_at_micros: i64,
|
||||
success: bool,
|
||||
failure_reason: Option<String>,
|
||||
provider_request_id: Option<String>,
|
||||
result_payload: Option<Value>,
|
||||
) {
|
||||
let completed_at_micros = current_utc_micros();
|
||||
let duration_ms = completed_at_micros.saturating_sub(started_at_micros).max(0) / 1_000;
|
||||
let mut draft = TrackingEventDraft::new("external_generation_run", "external-generation");
|
||||
draft.scope_kind = RuntimeTrackingScopeKind::Module;
|
||||
draft.scope_id = provider.to_string();
|
||||
draft.metadata = json!({
|
||||
"runId": format!("external-generation-{}", Uuid::new_v4()),
|
||||
"provider": provider,
|
||||
"operation": operation,
|
||||
"requestLabel": request_label.trim(),
|
||||
"requestPayload": request_payload,
|
||||
"status": if success { "succeeded" } else { "failed" },
|
||||
"success": success,
|
||||
"failureReason": failure_reason,
|
||||
"providerRequestId": provider_request_id,
|
||||
"resultPayload": result_payload,
|
||||
"startedAtMicros": started_at_micros,
|
||||
"completedAtMicros": completed_at_micros,
|
||||
"durationMs": duration_ms,
|
||||
});
|
||||
|
||||
record_tracking_event_after_success(state, &external_generation_request_context(), draft).await;
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000) as i64
|
||||
}
|
||||
|
||||
fn external_generation_request_context() -> RequestContext {
|
||||
RequestContext::new(
|
||||
format!("external-generation-{}", Uuid::new_v4()),
|
||||
"external generation run".to_string(),
|
||||
std::time::Duration::ZERO,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn record_route_tracking_event_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use serde_json::json;
|
||||
use shared_contracts::creation_audio;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
use crate::{
|
||||
http_error::AppError, state::AppState, tracking::record_external_generation_run_after_success,
|
||||
};
|
||||
|
||||
use super::{
|
||||
clock::current_utc_iso_text,
|
||||
clock::{current_utc_iso_text, current_utc_micros},
|
||||
errors::{map_platform_audio_error, vector_engine_bad_gateway},
|
||||
publish::wait_for_generated_audio_asset,
|
||||
tasks::{create_background_music_task_response, create_sound_effect_task_response},
|
||||
@@ -18,45 +21,69 @@ pub(crate) async fn generate_sound_effect_asset_for_creation(
|
||||
seed: Option<u64>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let normalized_prompt = platform_audio::normalize_limited_text(
|
||||
&prompt,
|
||||
"prompt",
|
||||
platform_audio::VIDU_PROMPT_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task =
|
||||
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed).await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
|
||||
let request_payload = json!({
|
||||
"kind": "sound_effect",
|
||||
"promptChars": normalized_prompt.chars().count(),
|
||||
"duration": duration,
|
||||
"seed": seed,
|
||||
"targetEntityKind": target.entity_kind,
|
||||
"targetEntityId": target.entity_id,
|
||||
"targetSlot": target.slot,
|
||||
"targetAssetKind": target.asset_kind,
|
||||
});
|
||||
let outcome = async {
|
||||
let task =
|
||||
create_sound_effect_task_response(state, normalized_prompt.clone(), duration, seed)
|
||||
.await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::SoundEffect,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("音效生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: None,
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
Ok::<_, AppError>(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: None,
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
}
|
||||
.await;
|
||||
record_creation_audio_generation_run(
|
||||
state,
|
||||
"sound_effect",
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
&outcome,
|
||||
)
|
||||
.await;
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_background_music_asset_for_creation(
|
||||
@@ -68,6 +95,7 @@ pub(crate) async fn generate_background_music_asset_for_creation(
|
||||
model: Option<String>,
|
||||
target: GeneratedCreationAudioTarget,
|
||||
) -> Result<creation_audio::CreationAudioAsset, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let normalized_prompt = platform_audio::normalize_limited_text_allow_empty(
|
||||
&prompt,
|
||||
"prompt",
|
||||
@@ -80,43 +108,111 @@ pub(crate) async fn generate_background_music_asset_for_creation(
|
||||
platform_audio::SUNO_TITLE_MAX_CHARS,
|
||||
)
|
||||
.map_err(map_platform_audio_error)?;
|
||||
let task = create_background_music_task_response(
|
||||
state,
|
||||
normalized_prompt.clone(),
|
||||
normalized_title.clone(),
|
||||
tags,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
|
||||
let request_payload = json!({
|
||||
"kind": "background_music",
|
||||
"promptChars": normalized_prompt.chars().count(),
|
||||
"titleChars": normalized_title.chars().count(),
|
||||
"hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()),
|
||||
"model": model,
|
||||
"targetEntityKind": target.entity_kind,
|
||||
"targetEntityId": target.entity_id,
|
||||
"targetSlot": target.slot,
|
||||
"targetAssetKind": target.asset_kind,
|
||||
});
|
||||
let outcome = async {
|
||||
let task = create_background_music_task_response(
|
||||
state,
|
||||
normalized_prompt.clone(),
|
||||
normalized_title.clone(),
|
||||
tags,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
let target = AudioAssetBindingTarget {
|
||||
storage_scope: target.entity_kind.clone(),
|
||||
entity_kind: target.entity_kind,
|
||||
entity_id: target.entity_id,
|
||||
slot: target.slot,
|
||||
asset_kind: target.asset_kind,
|
||||
profile_id: target.profile_id,
|
||||
storage_prefix: target.storage_prefix,
|
||||
};
|
||||
let generated = wait_for_generated_audio_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
task.task_id.clone(),
|
||||
AudioAssetSlot::BackgroundMusic,
|
||||
target,
|
||||
)
|
||||
.await?;
|
||||
let audio_src = generated
|
||||
.audio_src
|
||||
.ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?;
|
||||
|
||||
Ok(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: Some(normalized_title),
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
Ok::<_, AppError>(creation_audio::CreationAudioAsset {
|
||||
task_id: generated.task_id,
|
||||
provider: generated.provider,
|
||||
asset_object_id: generated.asset_object_id,
|
||||
asset_kind: generated.asset_kind,
|
||||
audio_src,
|
||||
prompt: Some(normalized_prompt),
|
||||
title: Some(normalized_title),
|
||||
updated_at: Some(current_utc_iso_text()),
|
||||
})
|
||||
}
|
||||
.await;
|
||||
record_creation_audio_generation_run(
|
||||
state,
|
||||
"background_music",
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
&outcome,
|
||||
)
|
||||
.await;
|
||||
outcome
|
||||
}
|
||||
|
||||
async fn record_creation_audio_generation_run(
|
||||
state: &AppState,
|
||||
operation: &'static str,
|
||||
request_payload: serde_json::Value,
|
||||
started_at_micros: i64,
|
||||
outcome: &Result<creation_audio::CreationAudioAsset, AppError>,
|
||||
) {
|
||||
match outcome {
|
||||
Ok(asset) => {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
asset.provider.as_str(),
|
||||
operation,
|
||||
"创作音频生成",
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
true,
|
||||
None,
|
||||
Some(asset.task_id.clone()),
|
||||
Some(json!({
|
||||
"assetObjectId": asset.asset_object_id,
|
||||
"assetKind": asset.asset_kind,
|
||||
"hasAudioSrc": !asset.audio_src.trim().is_empty(),
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
record_external_generation_run_after_success(
|
||||
state,
|
||||
"vector-engine-audio",
|
||||
operation,
|
||||
"创作音频生成",
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
false,
|
||||
Some(error.to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ use shared_contracts::wooden_fish::{
|
||||
WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse,
|
||||
WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse,
|
||||
WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest,
|
||||
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
@@ -1811,7 +1811,10 @@ pub fn select_runtime_next_profile<'a>(
|
||||
prefer_similar_work: bool,
|
||||
) -> Option<&'a PuzzleWorkProfile> {
|
||||
if prefer_similar_work {
|
||||
similar_work_profiles.first().copied().or(same_work_next_profile)
|
||||
similar_work_profiles
|
||||
.first()
|
||||
.copied()
|
||||
.or(same_work_next_profile)
|
||||
} else {
|
||||
same_work_next_profile.or_else(|| similar_work_profiles.first().copied())
|
||||
}
|
||||
@@ -3281,7 +3284,10 @@ mod tests {
|
||||
assert_eq!(failed.generation_status, "failed");
|
||||
assert_eq!(failed.levels[0].generation_status, "failed");
|
||||
assert_eq!(failed.levels[1].generation_status, "ready");
|
||||
assert_eq!(failed.levels[1].cover_image_src.as_deref(), Some("/ready.png"));
|
||||
assert_eq!(
|
||||
failed.levels[1].cover_image_src.as_deref(),
|
||||
Some("/ready.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3338,12 +3344,8 @@ mod tests {
|
||||
let same_work = build_published_profile("same", "owner-a", vec!["奇幻"]);
|
||||
let similar_work = build_published_profile("similar", "owner-b", vec!["奇幻"]);
|
||||
let similar_work_profiles = [&similar_work];
|
||||
let selected = select_runtime_next_profile(
|
||||
Some(&same_work),
|
||||
&similar_work_profiles,
|
||||
true,
|
||||
)
|
||||
.expect("should select similar work first");
|
||||
let selected = select_runtime_next_profile(Some(&same_work), &similar_work_profiles, true)
|
||||
.expect("should select similar work first");
|
||||
assert_eq!(selected.profile_id, "similar");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use serde_json::json;
|
||||
use shared_contracts::hyper3d as contract;
|
||||
|
||||
use super::status::normalize_task_status;
|
||||
use super::{
|
||||
build_submit_response, extract_download_files, extract_job_statuses,
|
||||
resolve_hyper3d_overall_status,
|
||||
};
|
||||
use super::status::normalize_task_status;
|
||||
|
||||
#[test]
|
||||
fn extracts_submit_response_from_nested_payload() {
|
||||
|
||||
@@ -53,15 +53,15 @@ pub use mapper::{
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
|
||||
|
||||
@@ -102,15 +102,15 @@ pub use self::puzzle::{
|
||||
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
||||
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord,
|
||||
PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord,
|
||||
PuzzleFormDraftRecord,
|
||||
PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord,
|
||||
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
||||
PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord,
|
||||
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput,
|
||||
PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput,
|
||||
PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord,
|
||||
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
|
||||
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
|
||||
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput,
|
||||
PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
};
|
||||
|
||||
@@ -183,15 +183,12 @@ impl SpacetimeClient {
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.mark_puzzle_draft_generation_failed_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_puzzle_agent_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
.mark_puzzle_draft_generation_failed_then(procedure_input, move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_puzzle_agent_session_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
});
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -10,8 +10,8 @@ use module_puzzle::{
|
||||
PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind,
|
||||
PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput,
|
||||
PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput,
|
||||
PuzzleDraftCompileFailureInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
|
||||
PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput,
|
||||
PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput,
|
||||
PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus,
|
||||
PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput,
|
||||
PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult,
|
||||
@@ -2193,7 +2193,7 @@ fn advance_puzzle_next_level_tx(
|
||||
&similar_work_profiles,
|
||||
input.prefer_similar_work,
|
||||
)
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
|
||||
.ok_or_else(|| "没有可用的下一关候选".to_string())?;
|
||||
let mut next_run = if similar_work_next_profile.is_some() {
|
||||
module_puzzle::advance_to_new_work_first_level_at(
|
||||
¤t_run,
|
||||
|
||||
Reference in New Issue
Block a user