Files
Genarrative/server-rs/crates/api-server/src/puzzle/mappers.rs
高物 ae014ac881 Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
2026-05-22 03:06:41 +08:00

665 lines
23 KiB
Rust

use super::*;
pub(super) fn map_puzzle_agent_session_response(
session: PuzzleAgentSessionRecord,
) -> PuzzleAgentSessionSnapshotResponse {
PuzzleAgentSessionSnapshotResponse {
session_id: session.session_id,
seed_text: session.seed_text,
current_turn: session.current_turn,
progress_percent: session.progress_percent,
stage: session.stage,
anchor_pack: map_puzzle_anchor_pack_response(session.anchor_pack),
draft: session.draft.map(map_puzzle_result_draft_response),
messages: session
.messages
.into_iter()
.map(map_puzzle_agent_message_response)
.collect(),
last_assistant_reply: session.last_assistant_reply,
published_profile_id: session.published_profile_id,
suggested_actions: session
.suggested_actions
.into_iter()
.map(map_puzzle_suggested_action_response)
.collect(),
result_preview: session
.result_preview
.map(map_puzzle_result_preview_response),
updated_at: session.updated_at,
}
}
pub(super) fn map_puzzle_anchor_pack_response(
anchor_pack: PuzzleAnchorPackRecord,
) -> PuzzleAnchorPackResponse {
PuzzleAnchorPackResponse {
theme_promise: map_puzzle_anchor_item_response(anchor_pack.theme_promise),
visual_subject: map_puzzle_anchor_item_response(anchor_pack.visual_subject),
visual_mood: map_puzzle_anchor_item_response(anchor_pack.visual_mood),
composition_hooks: map_puzzle_anchor_item_response(anchor_pack.composition_hooks),
tags_and_forbidden: map_puzzle_anchor_item_response(anchor_pack.tags_and_forbidden),
}
}
pub(super) fn map_puzzle_anchor_item_response(
anchor: PuzzleAnchorItemRecord,
) -> PuzzleAnchorItemResponse {
PuzzleAnchorItemResponse {
key: anchor.key,
label: anchor.label,
value: anchor.value,
status: anchor.status,
}
}
pub(super) fn map_puzzle_result_draft_response(
draft: PuzzleResultDraftRecord,
) -> PuzzleResultDraftResponse {
PuzzleResultDraftResponse {
work_title: draft.work_title,
work_description: draft.work_description,
level_name: draft.level_name,
summary: draft.summary,
theme_tags: draft.theme_tags,
forbidden_directives: draft.forbidden_directives,
creator_intent: draft.creator_intent.map(map_puzzle_creator_intent_response),
anchor_pack: map_puzzle_anchor_pack_response(draft.anchor_pack),
candidates: draft
.candidates
.into_iter()
.map(map_puzzle_generated_image_candidate_response)
.collect(),
selected_candidate_id: draft.selected_candidate_id,
cover_image_src: draft.cover_image_src,
cover_asset_id: draft.cover_asset_id,
generation_status: draft.generation_status,
levels: draft
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect(),
form_draft: draft.form_draft.map(map_puzzle_form_draft_response),
}
}
pub(super) fn map_puzzle_form_draft_response(
draft: PuzzleFormDraftRecord,
) -> PuzzleFormDraftResponse {
PuzzleFormDraftResponse {
work_title: draft.work_title,
work_description: draft.work_description,
picture_description: draft.picture_description,
}
}
pub(super) fn map_puzzle_draft_level_response(
level: PuzzleDraftLevelRecord,
) -> PuzzleDraftLevelResponse {
let generation_status = resolve_puzzle_level_generation_status(&level);
PuzzleDraftLevelResponse {
level_id: level.level_id,
level_name: level.level_name,
picture_description: level.picture_description,
picture_reference: level.picture_reference,
ui_background_prompt: level.ui_background_prompt,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
level_scene_image_src: level.level_scene_image_src,
level_scene_image_object_key: level.level_scene_image_object_key,
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
level_background_image_src: level.level_background_image_src,
level_background_image_object_key: level.level_background_image_object_key,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
candidates: level
.candidates
.into_iter()
.map(map_puzzle_generated_image_candidate_response)
.collect(),
selected_candidate_id: level.selected_candidate_id,
cover_image_src: level.cover_image_src,
cover_asset_id: level.cover_asset_id,
generation_status,
}
}
pub(super) fn map_puzzle_audio_asset_record_response(
asset: PuzzleAudioAssetRecord,
) -> CreationAudioAsset {
CreationAudioAsset {
task_id: asset.task_id,
provider: asset.provider,
asset_object_id: asset.asset_object_id,
asset_kind: asset.asset_kind,
audio_src: asset.audio_src,
prompt: asset.prompt,
title: asset.title,
updated_at: asset.updated_at,
}
}
pub(super) fn map_puzzle_audio_asset_domain_record(
asset: module_puzzle::PuzzleAudioAsset,
) -> PuzzleAudioAssetRecord {
PuzzleAudioAssetRecord {
task_id: asset.task_id,
provider: asset.provider,
asset_object_id: asset.asset_object_id,
asset_kind: asset.asset_kind,
audio_src: asset.audio_src,
prompt: asset.prompt,
title: asset.title,
updated_at: asset.updated_at,
}
}
pub(super) fn puzzle_audio_asset_response_module_json(asset: &Option<CreationAudioAsset>) -> Value {
asset
.as_ref()
.map(|asset| {
json!({
"task_id": asset.task_id,
"provider": asset.provider,
"asset_object_id": asset.asset_object_id,
"asset_kind": asset.asset_kind,
"audio_src": asset.audio_src,
"prompt": asset.prompt,
"title": asset.title,
"updated_at": asset.updated_at,
})
})
.unwrap_or(Value::Null)
}
pub(super) fn puzzle_audio_asset_record_module_json(
asset: &Option<PuzzleAudioAssetRecord>,
) -> Value {
asset
.as_ref()
.map(|asset| {
json!({
"task_id": asset.task_id,
"provider": asset.provider,
"asset_object_id": asset.asset_object_id,
"asset_kind": asset.asset_kind,
"audio_src": asset.audio_src,
"prompt": asset.prompt,
"title": asset.title,
"updated_at": asset.updated_at,
})
})
.unwrap_or(Value::Null)
}
pub(super) fn map_puzzle_creator_intent_response(
intent: PuzzleCreatorIntentRecord,
) -> PuzzleCreatorIntentResponse {
PuzzleCreatorIntentResponse {
source_mode: intent.source_mode,
raw_messages_summary: intent.raw_messages_summary,
theme_promise: intent.theme_promise,
visual_subject: intent.visual_subject,
visual_mood: intent.visual_mood,
composition_hooks: intent.composition_hooks,
theme_tags: intent.theme_tags,
forbidden_directives: intent.forbidden_directives,
}
}
pub(super) fn map_puzzle_generated_image_candidate_response(
candidate: PuzzleGeneratedImageCandidateRecord,
) -> PuzzleGeneratedImageCandidateResponse {
PuzzleGeneratedImageCandidateResponse {
candidate_id: candidate.candidate_id,
image_src: candidate.image_src,
asset_id: candidate.asset_id,
prompt: candidate.prompt,
actual_prompt: candidate.actual_prompt,
source_type: candidate.source_type,
selected: candidate.selected,
}
}
pub(super) fn map_puzzle_agent_message_response(
message: PuzzleAgentMessageRecord,
) -> PuzzleAgentMessageResponse {
PuzzleAgentMessageResponse {
id: message.message_id,
role: message.role,
kind: message.kind,
text: message.text,
created_at: message.created_at,
}
}
pub(super) fn map_puzzle_suggested_action_response(
action: PuzzleAgentSuggestedActionRecord,
) -> PuzzleAgentSuggestedActionResponse {
PuzzleAgentSuggestedActionResponse {
id: action.action_id,
action_type: action.action_type,
label: action.label,
}
}
pub(super) fn map_puzzle_result_preview_response(
preview: PuzzleResultPreviewRecord,
) -> PuzzleResultPreviewEnvelopeResponse {
PuzzleResultPreviewEnvelopeResponse {
draft: map_puzzle_result_draft_response(preview.draft),
blockers: preview
.blockers
.into_iter()
.map(map_puzzle_result_preview_blocker_response)
.collect(),
quality_findings: preview
.quality_findings
.into_iter()
.map(map_puzzle_result_preview_finding_response)
.collect(),
publish_ready: preview.publish_ready,
}
}
pub(super) fn map_puzzle_result_preview_blocker_response(
blocker: PuzzleResultPreviewBlockerRecord,
) -> PuzzleResultPreviewBlockerResponse {
PuzzleResultPreviewBlockerResponse {
id: blocker.blocker_id,
code: blocker.code,
message: blocker.message,
}
}
pub(super) fn map_puzzle_result_preview_finding_response(
finding: PuzzleResultPreviewFindingRecord,
) -> PuzzleResultPreviewFindingResponse {
PuzzleResultPreviewFindingResponse {
id: finding.finding_id,
severity: finding.severity,
code: finding.code,
message: finding.message,
}
}
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
let has_viewable_result = item
.cover_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| item.levels.iter().any(has_puzzle_level_image);
if has_viewable_result {
return Some("ready".to_string());
}
item.levels
.iter()
.map(resolve_puzzle_level_generation_status)
.find(|status| status.as_str() == "generating")
.or_else(|| {
item.levels
.iter()
.map(resolve_puzzle_level_generation_status)
.find(|status| status.as_str() == "ready")
})
.or_else(|| {
item.levels
.iter()
.map(resolve_puzzle_level_generation_status)
.find(|status| !status.is_empty())
})
}
fn resolve_puzzle_level_generation_status(level: &PuzzleDraftLevelRecord) -> String {
if level.generation_status.trim() == "generating" && has_puzzle_level_image(level) {
return "ready".to_string();
}
level.generation_status.trim().to_string()
}
fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool {
let has_cover = level
.cover_image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let has_selected_candidate = level
.selected_candidate_id
.as_deref()
.and_then(|candidate_id| {
level
.candidates
.iter()
.find(|candidate| candidate.candidate_id == candidate_id)
})
.map(|candidate| candidate.image_src.trim())
.is_some_and(|value| !value.is_empty());
let has_fallback_candidate = level
.candidates
.last()
.map(|candidate| candidate.image_src.trim())
.is_some_and(|value| !value.is_empty());
has_cover || has_selected_candidate || has_fallback_candidate
}
pub(super) fn map_puzzle_work_summary_response(
state: &PuzzleApiState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkSummaryResponse {
let generation_status = resolve_puzzle_work_generation_status(&item);
let author = resolve_puzzle_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
PuzzleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
author_display_name: author.display_name,
work_title: item.work_title,
work_description: item.work_description,
level_name: item.level_name,
summary: item.summary,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
cover_asset_id: item.cover_asset_id,
publication_status: item.publication_status,
updated_at: item.updated_at,
published_at: item.published_at,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
point_incentive_total_half_points: item.point_incentive_total_half_points,
point_incentive_claimed_points: item.point_incentive_claimed_points,
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
point_incentive_claimable_points: item
.point_incentive_total_half_points
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
generation_status,
levels: item
.levels
.iter()
.map(|x| map_puzzle_draft_level_response(x.clone()))
.collect(),
}
}
pub(super) fn map_puzzle_gallery_card_response(
state: &PuzzleApiState,
item: PuzzleGalleryCardRecord,
) -> PuzzleWorkSummaryResponse {
let author = resolve_puzzle_work_author_by_user_id(
state,
&item.owner_user_id,
Some(&item.author_display_name),
None,
);
PuzzleWorkSummaryResponse {
work_id: item.work_id,
profile_id: item.profile_id,
owner_user_id: item.owner_user_id,
source_session_id: item.source_session_id,
author_display_name: author.display_name,
work_title: item.work_title,
work_description: item.work_description,
level_name: item.level_name,
summary: item.summary,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
cover_asset_id: item.cover_asset_id,
publication_status: item.publication_status,
updated_at: item.updated_at,
published_at: item.published_at,
play_count: item.play_count,
remix_count: item.remix_count,
like_count: item.like_count,
recent_play_count_7d: item.recent_play_count_7d,
point_incentive_total_half_points: item.point_incentive_total_half_points,
point_incentive_claimed_points: item.point_incentive_claimed_points,
point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0,
point_incentive_claimable_points: item
.point_incentive_total_half_points
.saturating_div(2)
.saturating_sub(item.point_incentive_claimed_points),
publish_ready: item.publish_ready,
generation_status: item.generation_status,
levels: Vec::new(),
}
}
pub(super) fn map_puzzle_work_profile_response(
state: &PuzzleApiState,
item: PuzzleWorkProfileRecord,
) -> PuzzleWorkProfileResponse {
let mut summary = map_puzzle_work_summary_response(state, item.clone());
summary.levels = item
.levels
.into_iter()
.map(map_puzzle_draft_level_response)
.collect();
PuzzleWorkProfileResponse {
summary,
anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack),
}
}
pub(super) fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
PuzzleRunSnapshotResponse {
run_id: run.run_id,
entry_profile_id: run.entry_profile_id,
cleared_level_count: run.cleared_level_count,
current_level_index: run.current_level_index,
current_grid_size: run.current_grid_size,
played_profile_ids: run.played_profile_ids,
previous_level_tags: run.previous_level_tags,
current_level: run.current_level.map(map_puzzle_runtime_level_response),
recommended_next_profile_id: run.recommended_next_profile_id,
next_level_mode: run.next_level_mode,
next_level_profile_id: run.next_level_profile_id,
next_level_id: run.next_level_id,
recommended_next_works: run
.recommended_next_works
.into_iter()
.map(map_puzzle_recommended_next_work_response)
.collect(),
leaderboard_entries: run
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
pub(super) fn map_puzzle_recommended_next_work_response(
item: PuzzleRecommendedNextWorkRecord,
) -> PuzzleRecommendedNextWorkResponse {
PuzzleRecommendedNextWorkResponse {
profile_id: item.profile_id,
level_name: item.level_name,
author_display_name: item.author_display_name,
theme_tags: item.theme_tags,
cover_image_src: item.cover_image_src,
similarity_score: item.similarity_score,
}
}
pub(super) async fn enrich_puzzle_run_author_name(
state: &PuzzleApiState,
mut run: PuzzleRunRecord,
) -> PuzzleRunRecord {
if let Some(level) = run.current_level.as_mut() {
if let Ok(profile) = state
.spacetime_client()
.get_puzzle_gallery_detail(level.profile_id.clone())
.await
{
level.author_display_name = resolve_puzzle_work_author_by_user_id(
state,
&profile.owner_user_id,
Some(&profile.author_display_name),
None,
)
.display_name;
}
}
run
}
pub(super) fn map_puzzle_runtime_level_response(
level: spacetime_client::PuzzleRuntimeLevelRecord,
) -> PuzzleRuntimeLevelSnapshotResponse {
let timer_defaults =
build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size);
let time_limit_ms = if level.time_limit_ms == 0 {
timer_defaults.time_limit_ms
} else {
level.time_limit_ms
};
let remaining_ms =
if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing.as_str() {
time_limit_ms
} else {
level.remaining_ms.min(time_limit_ms)
};
PuzzleRuntimeLevelSnapshotResponse {
run_id: level.run_id,
level_index: level.level_index,
level_id: level.level_id,
grid_size: level.grid_size,
profile_id: level.profile_id,
level_name: level.level_name,
author_display_name: level.author_display_name,
theme_tags: level.theme_tags,
cover_image_src: level.cover_image_src,
ui_background_image_src: level.ui_background_image_src,
ui_background_image_object_key: level.ui_background_image_object_key,
level_background_image_src: level.level_background_image_src,
level_background_image_object_key: level.level_background_image_object_key,
ui_spritesheet_image_src: level.ui_spritesheet_image_src,
ui_spritesheet_image_object_key: level.ui_spritesheet_image_object_key,
background_music: level
.background_music
.map(map_puzzle_audio_asset_record_response),
board: map_puzzle_board_response(level.board),
status: level.status,
started_at_ms: level.started_at_ms,
cleared_at_ms: level.cleared_at_ms,
elapsed_ms: level.elapsed_ms,
time_limit_ms,
remaining_ms,
paused_accumulated_ms: level.paused_accumulated_ms,
pause_started_at_ms: level.pause_started_at_ms,
freeze_accumulated_ms: level.freeze_accumulated_ms,
freeze_started_at_ms: level.freeze_started_at_ms,
freeze_until_ms: level.freeze_until_ms,
leaderboard_entries: level
.leaderboard_entries
.into_iter()
.map(map_puzzle_leaderboard_entry_response)
.collect(),
}
}
struct PuzzleRuntimeTimerResponseDefaults {
time_limit_ms: u64,
}
fn build_puzzle_runtime_timer_response_defaults(
level_index: u32,
grid_size: u32,
) -> PuzzleRuntimeTimerResponseDefaults {
let time_limit_ms = if level_index > 0 {
module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index)
} else {
module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size)
};
PuzzleRuntimeTimerResponseDefaults { time_limit_ms }
}
pub(super) fn map_puzzle_leaderboard_entry_response(
entry: PuzzleLeaderboardEntryRecord,
) -> PuzzleLeaderboardEntryResponse {
PuzzleLeaderboardEntryResponse {
rank: entry.rank,
nickname: entry.nickname,
elapsed_ms: entry.elapsed_ms,
visible_tags: entry.visible_tags,
is_current_player: entry.is_current_player,
}
}
pub(super) fn map_puzzle_board_response(
board: spacetime_client::PuzzleBoardRecord,
) -> PuzzleBoardSnapshotResponse {
PuzzleBoardSnapshotResponse {
rows: board.rows,
cols: board.cols,
pieces: board
.pieces
.into_iter()
.map(|piece| PuzzlePieceStateResponse {
piece_id: piece.piece_id,
correct_row: piece.correct_row,
correct_col: piece.correct_col,
current_row: piece.current_row,
current_col: piece.current_col,
merged_group_id: piece.merged_group_id,
})
.collect(),
merged_groups: board
.merged_groups
.into_iter()
.map(|group| PuzzleMergedGroupStateResponse {
group_id: group.group_id,
piece_ids: group.piece_ids,
occupied_cells: group
.occupied_cells
.into_iter()
.map(|cell| PuzzleCellPositionResponse {
row: cell.row,
col: cell.col,
})
.collect(),
})
.collect(),
selected_piece_id: board.selected_piece_id,
all_tiles_resolved: board.all_tiles_resolved,
}
}
pub(super) fn resolve_author_display_name(
state: &PuzzleApiState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
pub(super) fn build_puzzle_welcome_text(seed_text: &str) -> String {
if seed_text.trim().is_empty() {
return "拼图创作信息已准备好。".to_string();
}
"拼图创作信息已准备好。".to_string()
}