feat: unify phase one creation flow

This commit is contained in:
2026-05-30 05:05:02 +08:00
parent 3a87b2d966
commit 26975644b5
33 changed files with 2037 additions and 539 deletions

View File

@@ -100,6 +100,7 @@ fn map_wooden_fish_session_snapshot(
fn map_wooden_fish_work_snapshot(
snapshot: WoodenFishWorkSnapshot,
) -> Result<WoodenFishWorkProfileResponse, SpacetimeClientError> {
let generation_status = parse_generation_status(&snapshot.generation_status);
let draft = WoodenFishDraftResponse {
template_id: "wooden-fish".to_string(),
template_name: "敲木鱼".to_string(),
@@ -116,15 +117,23 @@ fn map_wooden_fish_work_snapshot(
back_button_asset: snapshot.back_button_asset.clone().map(map_image_asset),
hit_sound_asset: snapshot.hit_sound_asset.clone().map(map_audio_asset),
cover_image_src: empty_string_to_none(snapshot.cover_image_src.clone()),
generation_status: parse_generation_status(&snapshot.generation_status),
generation_status: generation_status.clone(),
};
let hit_object_asset = draft
.hit_object_asset
.clone()
.or_else(|| {
matches!(generation_status, WoodenFishGenerationStatus::Failed)
.then(default_failed_hit_object_asset)
})
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit object asset"))?;
let hit_sound_asset = draft
.hit_sound_asset
.clone()
.or_else(|| {
matches!(generation_status, WoodenFishGenerationStatus::Failed)
.then(default_failed_hit_sound_asset)
})
.ok_or_else(|| SpacetimeClientError::missing_snapshot("wooden fish hit sound asset"))?;
Ok(WoodenFishWorkProfileResponse {
summary: WoodenFishWorkSummaryResponse {
@@ -143,7 +152,7 @@ fn map_wooden_fish_work_snapshot(
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
publish_ready: snapshot.publish_ready,
generation_status: parse_generation_status(&snapshot.generation_status),
generation_status,
},
draft,
hit_object_asset,
@@ -154,6 +163,31 @@ fn map_wooden_fish_work_snapshot(
})
}
fn default_failed_hit_object_asset() -> WoodenFishImageAsset {
WoodenFishImageAsset {
asset_id: "wooden-fish-failed-hit-object".to_string(),
image_src: "/wooden-fish/default-hit-object.png".to_string(),
image_object_key: "public/wooden-fish/default-hit-object.png".to_string(),
asset_object_id: "wooden-fish-failed-hit-object".to_string(),
generation_provider: "failed-fallback".to_string(),
prompt: "生成失败占位图".to_string(),
width: 1024,
height: 1024,
}
}
fn default_failed_hit_sound_asset() -> WoodenFishAudioAsset {
WoodenFishAudioAsset {
asset_id: "wooden-fish-failed-hit-sound".to_string(),
audio_src: "/wooden-fish/default-hit-sound.mp3".to_string(),
audio_object_key: "public/wooden-fish/default-hit-sound.mp3".to_string(),
asset_object_id: "wooden-fish-failed-hit-sound".to_string(),
source: "failed-fallback".to_string(),
prompt: Some("生成失败占位音效".to_string()),
duration_ms: Some(3_000),
}
}
fn map_wooden_fish_draft_snapshot(snapshot: WoodenFishDraftSnapshot) -> WoodenFishDraftResponse {
WoodenFishDraftResponse {
template_id: snapshot.template_id,

View File

@@ -122,6 +122,35 @@ impl SpacetimeClient {
})
}
pub async fn mark_wooden_fish_generation_failed(
&self,
session_id: String,
owner_user_id: String,
author_display_name: String,
) -> Result<WoodenFishSessionSnapshotResponse, SpacetimeClientError> {
let current = self
.get_wooden_fish_session(session_id.clone(), owner_user_id.clone())
.await?;
let mut draft = current.draft.clone().unwrap_or_else(default_draft);
let profile_id = resolve_wooden_fish_profile_id(
&draft,
&WoodenFishActionType::CompileDraft,
draft.profile_id.as_deref(),
)?;
draft.profile_id = Some(profile_id.clone());
draft.generation_status = WoodenFishGenerationStatus::Failed;
let now_micros = current_unix_micros();
self.compile_wooden_fish_draft(build_failed_compile_input(
&current,
&owner_user_id,
&author_display_name,
&profile_id,
&draft,
now_micros,
)?)
.await
}
pub async fn compile_wooden_fish_draft(
&self,
procedure_input: WoodenFishDraftCompileInput,
@@ -636,6 +665,52 @@ fn build_compile_input(
})
}
fn build_failed_compile_input(
current: &WoodenFishSessionSnapshotResponse,
owner_user_id: &str,
author_display_name: &str,
profile_id: &str,
draft: &WoodenFishDraftResponse,
now_micros: i64,
) -> Result<WoodenFishDraftCompileInput, SpacetimeClientError> {
Ok(WoodenFishDraftCompileInput {
session_id: current.session_id.clone(),
owner_user_id: owner_user_id.to_string(),
profile_id: profile_id.to_string(),
author_display_name: author_display_name.trim().to_string(),
work_title: draft.work_title.clone(),
work_description: draft.work_description.clone(),
theme_tags_json: Some(json_string(&draft.theme_tags)?),
hit_object_prompt: draft.hit_object_prompt.clone(),
hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(),
hit_sound_prompt: draft.hit_sound_prompt.clone(),
hit_object_asset_json: draft
.hit_object_asset
.as_ref()
.map(json_string)
.transpose()?,
background_asset_json: draft
.background_asset
.as_ref()
.map(json_string)
.transpose()?,
hit_sound_asset_json: draft
.hit_sound_asset
.as_ref()
.map(json_string)
.transpose()?,
back_button_asset_json: draft
.back_button_asset
.as_ref()
.map(json_string)
.transpose()?,
floating_words_json: Some(json_string(&draft.floating_words)?),
cover_image_src: draft.cover_image_src.clone(),
generation_status: Some("failed".to_string()),
compiled_at_micros: now_micros,
})
}
fn build_update_input(
owner_user_id: &str,
profile_id: &str,
@@ -801,6 +876,7 @@ mod tests {
const SESSION_ID: &str = "wooden-fish-session-test";
const OWNER_USER_ID: &str = "user-test";
const AUTHOR_DISPLAY_NAME: &str = "测试玩家";
const PROFILE_ID: &str = "wooden-fish-profile-test";
const NOW_MICROS: i64 = 1_763_456_789_000_000;
@@ -813,9 +889,14 @@ mod tests {
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
let (plan, draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("compile-draft should build plan");
let (plan, draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("compile-draft should build plan");
let WoodenFishActionProcedure::Compile(input) = plan else {
panic!("compile-draft should call compile_wooden_fish_draft");
@@ -862,11 +943,16 @@ mod tests {
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
Err(error) => error,
};
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not synthesize fake hit sound assets"),
Err(error) => error,
};
assert!(
error
@@ -883,11 +969,16 @@ mod tests {
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
payload.back_button_asset = Some(generated_back_button_asset("generated-compile-back"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
Ok(_) => panic!("compile-draft should not publish without background asset"),
Err(error) => error,
};
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not publish without background asset"),
Err(error) => error,
};
assert!(
error
@@ -904,11 +995,16 @@ mod tests {
payload.background_asset = Some(generated_background_asset("generated-compile-background"));
payload.hit_sound_asset = Some(generated_hit_sound_asset("generated-compile-sound"));
let error =
match build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS) {
Ok(_) => panic!("compile-draft should not publish without back button asset"),
Err(error) => error,
};
let error = match build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
) {
Ok(_) => panic!("compile-draft should not publish without back button asset"),
Err(error) => error,
};
assert!(
error
@@ -926,9 +1022,14 @@ mod tests {
payload.background_asset = Some(generated_background_asset("generated-background"));
payload.back_button_asset = Some(generated_back_button_asset("generated-back"));
let (plan, _draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("regenerate-hit-object should build plan");
let (plan, _draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("regenerate-hit-object should build plan");
let WoodenFishActionProcedure::Compile(input) = plan else {
panic!("regenerate-hit-object should call compile_wooden_fish_draft");
@@ -987,9 +1088,14 @@ mod tests {
"健康+1".to_string(),
]);
let (plan, draft) =
build_wooden_fish_action_plan(&session, OWNER_USER_ID, &payload, NOW_MICROS)
.expect("update-floating-words should build plan");
let (plan, draft) = build_wooden_fish_action_plan(
&session,
OWNER_USER_ID,
AUTHOR_DISPLAY_NAME,
&payload,
NOW_MICROS,
)
.expect("update-floating-words should build plan");
let WoodenFishActionProcedure::Update(input) = plan else {
panic!("update-floating-words should call update_wooden_fish_work");
@@ -1016,6 +1122,31 @@ mod tests {
assert!(draft.hit_sound_prompt.is_none());
}
#[test]
fn wooden_fish_failed_compile_input_preserves_session_and_marks_failed() {
let session = session_with_draft(draft_without_assets());
let mut draft = session.draft.clone().expect("draft should exist");
draft.profile_id = Some(PROFILE_ID.to_string());
draft.generation_status = WoodenFishGenerationStatus::Failed;
let input = build_failed_compile_input(
&session,
OWNER_USER_ID,
"测试玩家",
PROFILE_ID,
&draft,
NOW_MICROS,
)
.expect("failed compile input should build");
assert_eq!(input.session_id, SESSION_ID);
assert_eq!(input.profile_id, PROFILE_ID);
assert_eq!(input.generation_status.as_deref(), Some("failed"));
assert!(input.hit_object_asset_json.is_none());
assert!(input.background_asset_json.is_none());
assert!(input.back_button_asset_json.is_none());
}
fn action(action_type: WoodenFishActionType) -> WoodenFishActionRequest {
WoodenFishActionRequest {
action_type,