d575b6887f
同步 origin/master 至 b303605bf
保留项目总控 Runtime 决策并合入后台账号与画布更新
沿用当前 platform-llm LlmRunRequest 接口解决背景决策冲突
363 lines
16 KiB
Rust
363 lines
16 KiB
Rust
use std::{
|
|
collections::BTreeMap,
|
|
time::{Instant, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use axum::{
|
|
Json,
|
|
extract::{Extension, Path as AxumPath, State, rejection::JsonRejection},
|
|
http::{HeaderName, StatusCode, header},
|
|
response::{
|
|
IntoResponse, Response,
|
|
sse::{Event, Sse},
|
|
},
|
|
};
|
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
|
use image::ImageFormat;
|
|
use module_assets::{
|
|
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
|
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
|
};
|
|
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
|
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmRunRequest};
|
|
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
|
use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
|
use serde_json::{Value, json};
|
|
use shared_contracts::{
|
|
creation_audio::CreationAudioAsset,
|
|
external_generation::{ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord},
|
|
puzzle_agent::{
|
|
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
|
|
PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse,
|
|
PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse,
|
|
PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse,
|
|
PuzzleCreatorIntentResponse, PuzzleDraftLevelResponse, PuzzleFormDraftResponse,
|
|
PuzzleGeneratedImageCandidateResponse, PuzzleResultDraftResponse,
|
|
PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse,
|
|
PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest,
|
|
},
|
|
puzzle_gallery::PuzzleGalleryDetailResponse,
|
|
puzzle_runtime::{
|
|
AdvancePuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
|
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
|
PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse,
|
|
PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest,
|
|
SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest,
|
|
UsePuzzleRuntimePropRequest,
|
|
},
|
|
puzzle_works::{
|
|
PutPuzzleWorkRequest, PuzzleOnboardingGenerateRequest, PuzzleOnboardingGenerateResponse,
|
|
PuzzleOnboardingSaveRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
|
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
|
|
},
|
|
};
|
|
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
|
use spacetime_client::{
|
|
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, PuzzleAgentMessageRecord,
|
|
PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput,
|
|
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
|
|
PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput,
|
|
PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleCreatorIntentRecord,
|
|
PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord,
|
|
PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord,
|
|
PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord,
|
|
PuzzleLeaderboardSubmitRecordInput, PuzzleLevelGenerationFailureRecordInput,
|
|
PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord,
|
|
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
|
PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
|
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput,
|
|
PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput,
|
|
PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
|
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
|
};
|
|
use std::convert::Infallible;
|
|
|
|
use crate::{
|
|
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
|
|
api_response::json_success_body,
|
|
asset_billing::{
|
|
execute_billable_asset_operation, execute_billable_asset_operation_with_cost,
|
|
should_skip_asset_operation_billing_for_connectivity,
|
|
},
|
|
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
|
external_generation_worker::{
|
|
PUZZLE_COMPILE_DRAFT_JOB_KIND, PUZZLE_GENERATE_IMAGES_JOB_KIND,
|
|
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND,
|
|
},
|
|
generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha,
|
|
http_error::AppError,
|
|
llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL},
|
|
openai_image_generation::{
|
|
DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client,
|
|
create_openai_image_generation, require_openai_image_settings,
|
|
},
|
|
platform_errors::map_oss_error,
|
|
prompt::puzzle::{
|
|
draft::{
|
|
PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt,
|
|
resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt,
|
|
},
|
|
image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
|
level_name::{
|
|
PUZZLE_FIRST_LEVEL_NAME_SYSTEM_PROMPT, build_puzzle_first_level_name_user_prompt,
|
|
build_puzzle_first_level_name_vision_user_text,
|
|
},
|
|
tags::{PUZZLE_TAG_GENERATION_SYSTEM_PROMPT, build_puzzle_tag_generation_user_prompt},
|
|
},
|
|
puzzle_agent_turn::{
|
|
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
|
run_puzzle_agent_turn,
|
|
},
|
|
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
|
|
request_context::RequestContext,
|
|
state::{AppState, PuzzleApiState},
|
|
wechat::subscribe_message::{
|
|
GenerationResultSubscribeMessage, GenerationResultSubscribeMessageStatus,
|
|
send_generation_result_subscribe_message_after_completion,
|
|
},
|
|
work_author::resolve_puzzle_work_author_by_user_id,
|
|
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
|
|
};
|
|
|
|
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
|
|
const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works";
|
|
const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
|
|
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
|
|
const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
|
|
const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview";
|
|
const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2;
|
|
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
|
#[cfg(test)]
|
|
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
|
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
|
const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
|
const PUZZLE_LEVEL_NAME_VISION_IMAGE_MAX_SIDE: u32 = 768;
|
|
const PUZZLE_LEVEL_NAME_VISION_MAX_TOKENS: u32 = 512;
|
|
const PUZZLE_REFERENCE_IMAGE_MAX_BYTES: usize = 6 * 1024 * 1024;
|
|
const PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT: usize = 5;
|
|
const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
|
|
"移动端拼图游戏纯背景,题材氛围清晰,不包含拼图槽或 UI 元素";
|
|
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
|
|
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
|
|
|
fn build_puzzle_background_compile_task_id(session_id: &str) -> String {
|
|
format!("puzzle_initial_background:{session_id}")
|
|
}
|
|
|
|
fn build_puzzle_background_compile_claim_id(task_id: &str, request_id: &str) -> String {
|
|
format!("{task_id}:{request_id}")
|
|
}
|
|
|
|
async fn release_claimed_puzzle_background_compile_task(
|
|
state: &PuzzleApiState,
|
|
task_id: &str,
|
|
claim_id: &str,
|
|
session_id: &str,
|
|
owner_user_id: &str,
|
|
) {
|
|
let result = state
|
|
.spacetime_client()
|
|
.release_puzzle_background_compile_task(PuzzleBackgroundCompileTaskReleaseRecordInput {
|
|
task_id: task_id.to_string(),
|
|
claim_id: claim_id.to_string(),
|
|
session_id: session_id.to_string(),
|
|
owner_user_id: owner_user_id.to_string(),
|
|
})
|
|
.await;
|
|
match result {
|
|
Ok(true) => {}
|
|
Ok(false) => {
|
|
tracing::warn!(
|
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
task_id,
|
|
claim_id,
|
|
session_id,
|
|
owner_user_id,
|
|
"拼图首图后台生成任务释放未命中当前 claim"
|
|
);
|
|
}
|
|
Err(error) => {
|
|
tracing::warn!(
|
|
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
|
task_id,
|
|
claim_id,
|
|
session_id,
|
|
owner_user_id,
|
|
error = %error,
|
|
"拼图首图后台生成任务释放失败"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn spawn_release_claimed_puzzle_background_compile_task(
|
|
state: PuzzleApiState,
|
|
task_id: String,
|
|
claim_id: String,
|
|
session_id: String,
|
|
owner_user_id: String,
|
|
) {
|
|
tokio::spawn(async move {
|
|
release_claimed_puzzle_background_compile_task(
|
|
&state,
|
|
&task_id,
|
|
&claim_id,
|
|
&session_id,
|
|
&owner_user_id,
|
|
)
|
|
.await;
|
|
});
|
|
}
|
|
|
|
fn has_puzzle_cover_image_src(value: &Option<String>) -> bool {
|
|
value
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.is_some_and(|value| !value.is_empty())
|
|
}
|
|
|
|
fn mark_puzzle_initial_generation_started_snapshot(
|
|
mut session: PuzzleAgentSessionRecord,
|
|
) -> PuzzleAgentSessionRecord {
|
|
session.stage = "image_refining".to_string();
|
|
session.progress_percent = session.progress_percent.max(88);
|
|
if let Some(draft) = session.draft.as_mut() {
|
|
let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src);
|
|
if let Some(primary_level) = draft.levels.first_mut() {
|
|
if !has_puzzle_cover_image_src(&primary_level.cover_image_src) {
|
|
primary_level.generation_status = "generating".to_string();
|
|
}
|
|
draft.generation_status = primary_level.generation_status.clone();
|
|
draft.candidates = primary_level.candidates.clone();
|
|
draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
|
|
draft.cover_image_src = primary_level.cover_image_src.clone();
|
|
draft.cover_asset_id = primary_level.cover_asset_id.clone();
|
|
} else if draft_needs_cover {
|
|
draft.generation_status = "generating".to_string();
|
|
}
|
|
}
|
|
session
|
|
}
|
|
|
|
fn map_puzzle_queue_job_status(
|
|
job: &ExternalGenerationJobRecord,
|
|
fallback_progress: u32,
|
|
) -> ExternalGenerationJobStatusRecord {
|
|
let (status, phase_detail, default_progress) = match job.status.as_str() {
|
|
"completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100),
|
|
"running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35),
|
|
"failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0),
|
|
_ => (ExternalGenerationJobStatus::Queued, "排队中。", 8),
|
|
};
|
|
|
|
ExternalGenerationJobStatusRecord {
|
|
operation_id: job.job_id.clone(),
|
|
status,
|
|
phase_label: job.request_label.clone(),
|
|
phase_detail: phase_detail.to_string(),
|
|
progress: fallback_progress
|
|
.max(default_progress)
|
|
.min(100)
|
|
.try_into()
|
|
.unwrap_or(100),
|
|
error: job.last_error_message.clone(),
|
|
updated_at_micros: job.updated_at_micros,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) struct ExternalGenerationWriteLeaseGuard {
|
|
pub(crate) job_id: Option<String>,
|
|
pub(crate) worker_id: Option<String>,
|
|
pub(crate) lease_token: Option<String>,
|
|
}
|
|
|
|
impl ExternalGenerationWriteLeaseGuard {
|
|
pub(crate) fn inline() -> Self {
|
|
Self {
|
|
job_id: None,
|
|
worker_id: None,
|
|
lease_token: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn from_claimed_job(job_id: String, worker_id: String, lease_token: String) -> Self {
|
|
Self {
|
|
job_id: Some(job_id),
|
|
worker_id: Some(worker_id),
|
|
lease_token: Some(lease_token),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) struct PuzzleExternalGenerationWorkerError {
|
|
error: AppError,
|
|
should_fail_queue_job: bool,
|
|
}
|
|
|
|
impl PuzzleExternalGenerationWorkerError {
|
|
pub(crate) fn with_failure_state_written(error: AppError) -> Self {
|
|
Self {
|
|
error,
|
|
should_fail_queue_job: true,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn with_failure_state_pending(error: AppError) -> Self {
|
|
Self {
|
|
error,
|
|
should_fail_queue_job: false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn body_text(&self) -> String {
|
|
self.error.body_text()
|
|
}
|
|
|
|
pub(crate) fn into_app_error(self) -> AppError {
|
|
self.error
|
|
}
|
|
|
|
pub(crate) fn should_fail_queue_job(&self) -> bool {
|
|
self.should_fail_queue_job
|
|
}
|
|
}
|
|
|
|
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
|
|
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
|
|
}
|
|
|
|
pub(crate) fn build_puzzle_reference_image_too_large_message(actual_bytes: usize) -> String {
|
|
format!(
|
|
"参考图过大,请压缩后再上传(当前 {},最多 6MB)。",
|
|
format_puzzle_reference_image_upload_bytes(actual_bytes)
|
|
)
|
|
}
|
|
|
|
const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字";
|
|
const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景的spritesheet。背景必须是统一单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。";
|
|
const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容";
|
|
mod handlers;
|
|
pub(crate) use self::handlers::*;
|
|
|
|
mod mappers;
|
|
|
|
use self::mappers::*;
|
|
|
|
mod draft;
|
|
pub(crate) use self::draft::*;
|
|
|
|
mod tags;
|
|
|
|
use self::tags::*;
|
|
|
|
mod generation;
|
|
mod vector_engine;
|
|
|
|
pub(crate) use self::generation::*;
|
|
use self::vector_engine::*;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|