1
This commit is contained in:
@@ -12,12 +12,16 @@ use axum::{
|
||||
sse::{Event, Sse},
|
||||
},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
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;
|
||||
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::{
|
||||
puzzle_agent::{
|
||||
@@ -64,6 +68,7 @@ use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
http_error::AppError,
|
||||
prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt},
|
||||
puzzle_agent_turn::{
|
||||
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
|
||||
run_puzzle_agent_turn,
|
||||
@@ -78,8 +83,6 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
|
||||
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
|
||||
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
|
||||
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||
const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||
"低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污";
|
||||
|
||||
pub async fn create_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
@@ -216,6 +219,7 @@ pub async fn submit_puzzle_agent_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &submitted_session,
|
||||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
},
|
||||
|_| {},
|
||||
)
|
||||
@@ -320,6 +324,7 @@ pub async fn stream_puzzle_agent_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &session,
|
||||
quick_fill_requested,
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
},
|
||||
move |text| {
|
||||
let _ = reply_tx.send(text.to_string());
|
||||
@@ -447,7 +452,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
(
|
||||
"compile_puzzle_draft",
|
||||
"完整拼图草稿",
|
||||
"已编译草稿、生成候选图并应用正式图片。",
|
||||
"已编译草稿、生成拼图图片并应用为正式图。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
@@ -468,7 +473,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| draft.summary.clone());
|
||||
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
||||
// 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。
|
||||
let candidate_count = 1;
|
||||
let candidate_start_index = draft.candidates.len();
|
||||
let candidates = generate_puzzle_image_candidates(
|
||||
&state,
|
||||
@@ -476,6 +482,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&prompt,
|
||||
payload.reference_image_src.as_deref(),
|
||||
candidate_count,
|
||||
candidate_start_index,
|
||||
)
|
||||
@@ -521,8 +528,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
};
|
||||
(
|
||||
"generate_puzzle_images",
|
||||
"候选图生成",
|
||||
"已生成 2 张候选拼图图像。",
|
||||
"拼图图片生成",
|
||||
"已生成并替换当前拼图图片。",
|
||||
session,
|
||||
)
|
||||
}
|
||||
@@ -1296,6 +1303,7 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse {
|
||||
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,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1381,6 +1389,10 @@ fn map_puzzle_runtime_level_response(
|
||||
cover_image_src: level.cover_image_src,
|
||||
board: map_puzzle_board_response(level.board),
|
||||
status: level.status,
|
||||
started_at_ms: 0,
|
||||
cleared_at_ms: None,
|
||||
elapsed_ms: None,
|
||||
leaderboard_entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1476,7 +1488,8 @@ async fn compile_puzzle_draft_with_initial_cover(
|
||||
&compiled_session.session_id,
|
||||
&draft.level_name,
|
||||
&draft.summary,
|
||||
2,
|
||||
None,
|
||||
1,
|
||||
draft.candidates.len(),
|
||||
)
|
||||
.await
|
||||
@@ -1619,23 +1632,53 @@ async fn generate_puzzle_image_candidates(
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
reference_image_src: Option<&str>,
|
||||
candidate_count: u32,
|
||||
candidate_start_index: usize,
|
||||
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
||||
let count = candidate_count.clamp(1, 2);
|
||||
let count = candidate_count.clamp(1, 1);
|
||||
let settings =
|
||||
require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?;
|
||||
let http_client = build_puzzle_dashscope_http_client(&settings)
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
let generated = create_puzzle_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
build_puzzle_image_prompt(level_name, prompt).as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
"1024*1024",
|
||||
count,
|
||||
)
|
||||
.await
|
||||
let actual_prompt = build_puzzle_image_prompt(level_name, prompt);
|
||||
let reference_image = match reference_image_src
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
Some(source) => Some(
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, source)
|
||||
.await
|
||||
.map_err(|error| error.message().to_string())?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。
|
||||
let generated = match reference_image.as_deref() {
|
||||
Some(reference_image) => {
|
||||
create_puzzle_image_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
"1024*1024",
|
||||
count,
|
||||
reference_image,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
create_puzzle_text_to_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
"1024*1024",
|
||||
count,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(|error| error.message().to_string())?;
|
||||
let mut items = Vec::with_capacity(generated.images.len());
|
||||
|
||||
@@ -1661,9 +1704,10 @@ async fn generate_puzzle_image_candidates(
|
||||
image_src: asset.image_src,
|
||||
asset_id: asset.asset_id,
|
||||
prompt: prompt.to_string(),
|
||||
actual_prompt: Some(prompt.to_string()),
|
||||
actual_prompt: Some(actual_prompt.clone()),
|
||||
source_type: "generated".to_string(),
|
||||
selected: candidate_start_index == 0 && index == 0,
|
||||
// 单图生成结果总是直接成为当前正式图。
|
||||
selected: index == 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1740,7 +1784,8 @@ async fn build_local_next_puzzle_run(
|
||||
&session.session_id,
|
||||
&draft.level_name,
|
||||
&draft.summary,
|
||||
2,
|
||||
None,
|
||||
1,
|
||||
draft.candidates.len(),
|
||||
)
|
||||
.await
|
||||
@@ -1946,6 +1991,7 @@ fn build_local_puzzle_board(grid_size: u32) -> PuzzleBoardRecord {
|
||||
struct PuzzleDashScopeSettings {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
reference_image_model: String,
|
||||
request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
@@ -1960,6 +2006,11 @@ struct PuzzleDownloadedImage {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
struct ParsedPuzzleImageDataUrl {
|
||||
mime_type: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
struct GeneratedPuzzleAssetResponse {
|
||||
image_src: String,
|
||||
asset_id: String,
|
||||
@@ -1994,6 +2045,7 @@ fn require_puzzle_dashscope_settings(
|
||||
Ok(PuzzleDashScopeSettings {
|
||||
base_url: base_url.to_string(),
|
||||
api_key: api_key.to_string(),
|
||||
reference_image_model: state.config.dashscope_reference_image_model.clone(),
|
||||
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
@@ -2036,7 +2088,7 @@ async fn create_puzzle_text_to_image_generation(
|
||||
candidate_count: u32,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let mut parameters = Map::from_iter([
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 2))),
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||||
("size".to_string(), Value::String(size.to_string())),
|
||||
("prompt_extend".to_string(), Value::Bool(true)),
|
||||
("watermark".to_string(), Value::Bool(false)),
|
||||
@@ -2127,7 +2179,7 @@ async fn create_puzzle_text_to_image_generation(
|
||||
let mut images = Vec::with_capacity(image_urls.len());
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 2) as usize)
|
||||
.take(candidate_count.clamp(1, 1) as usize)
|
||||
{
|
||||
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||
}
|
||||
@@ -2150,6 +2202,270 @@ async fn create_puzzle_text_to_image_generation(
|
||||
)
|
||||
}
|
||||
|
||||
async fn resolve_puzzle_reference_image_as_data_url(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
source: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let trimmed = source.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图不能为空。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||
return Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
parsed.mime_type,
|
||||
BASE64_STANDARD.encode(parsed.bytes)
|
||||
));
|
||||
}
|
||||
|
||||
if !trimmed.starts_with('/') {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let object_key = trimmed.trim_start_matches('/');
|
||||
if LegacyAssetPrefix::from_object_key(object_key).is_none() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图当前只支持 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: object_key.to_string(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
let response = http_client
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图参考图失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图参考图内容失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取参考图失败,状态码:{status}"),
|
||||
"objectKey": object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if body.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": "读取参考图失败:对象内容为空",
|
||||
"objectKey": object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
content_type,
|
||||
BASE64_STANDARD.encode(body)
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_puzzle_image_to_image_generation(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &PuzzleDashScopeSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_image: &str,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let mut content = vec![json!({ "image": reference_image })];
|
||||
content.push(json!({ "text": prompt }));
|
||||
|
||||
let response = http_client
|
||||
.post(format!(
|
||||
"{}/services/aigc/multimodal-generation/generation",
|
||||
settings.base_url
|
||||
))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.json(&json!({
|
||||
"model": settings.reference_image_model.as_str(),
|
||||
"input": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": content,
|
||||
}
|
||||
],
|
||||
},
|
||||
"parameters": {
|
||||
"n": candidate_count.clamp(1, 1),
|
||||
"size": size,
|
||||
"negative_prompt": negative_prompt,
|
||||
"prompt_extend": true,
|
||||
"watermark": false,
|
||||
},
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("创建拼图参考图生成任务失败:{error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let response_text = response.text().await.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图参考图生成响应失败:{error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
response_text.as_str(),
|
||||
"创建拼图参考图生成任务失败",
|
||||
));
|
||||
}
|
||||
let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图参考图生成响应失败")?;
|
||||
let image_urls = extract_puzzle_image_urls(&payload);
|
||||
if image_urls.is_empty() {
|
||||
let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图参考图生成未返回 task_id 或图片地址",
|
||||
}))
|
||||
})?;
|
||||
return wait_puzzle_generated_images(
|
||||
http_client,
|
||||
settings,
|
||||
task_id.as_str(),
|
||||
candidate_count,
|
||||
"拼图参考图生成任务失败",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize);
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 1) as usize)
|
||||
{
|
||||
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||
}
|
||||
Ok(PuzzleGeneratedImages {
|
||||
task_id: format!("puzzle-ref-{}", current_utc_micros()),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_puzzle_generated_images(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &PuzzleDashScopeSettings,
|
||||
task_id: &str,
|
||||
candidate_count: u32,
|
||||
failure_message: &str,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms);
|
||||
|
||||
while Instant::now() < deadline {
|
||||
let poll_response = http_client
|
||||
.get(format!("{}/tasks/{}", settings.base_url, task_id))
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", settings.api_key),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}"))
|
||||
})?;
|
||||
let poll_status = poll_response.status();
|
||||
let poll_text = poll_response.text().await.map_err(|error| {
|
||||
map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}"))
|
||||
})?;
|
||||
if !poll_status.is_success() {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
poll_text.as_str(),
|
||||
"查询拼图图片生成任务失败",
|
||||
));
|
||||
}
|
||||
|
||||
let poll_payload =
|
||||
parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?;
|
||||
let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
if task_status == "SUCCEEDED" {
|
||||
let image_urls = extract_puzzle_image_urls(&poll_payload);
|
||||
if image_urls.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图图片生成成功但未返回图片地址",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mut images = Vec::with_capacity(image_urls.len());
|
||||
for image_url in image_urls
|
||||
.into_iter()
|
||||
.take(candidate_count.clamp(1, 1) as usize)
|
||||
{
|
||||
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
|
||||
}
|
||||
return Ok(PuzzleGeneratedImages {
|
||||
task_id: task_id.to_string(),
|
||||
images,
|
||||
});
|
||||
}
|
||||
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
|
||||
return Err(map_puzzle_dashscope_upstream_error(
|
||||
poll_text.as_str(),
|
||||
failure_message,
|
||||
));
|
||||
}
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
|
||||
Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "dashscope",
|
||||
"message": "拼图图片生成超时或未返回图片地址",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async fn download_puzzle_remote_image(
|
||||
http_client: &reqwest::Client,
|
||||
image_url: &str,
|
||||
@@ -2278,12 +2594,6 @@ async fn persist_puzzle_generated_asset(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
"生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_puzzle_asset_metadata(
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
@@ -2307,6 +2617,46 @@ fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<V
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_puzzle_image_data_url(value: &str) -> Option<ParsedPuzzleImageDataUrl> {
|
||||
let body = value.strip_prefix("data:")?;
|
||||
let (mime_type, data) = body.split_once(";base64,")?;
|
||||
if !mime_type.starts_with("image/") {
|
||||
return None;
|
||||
}
|
||||
let bytes = decode_puzzle_base64(data)?;
|
||||
Some(ParsedPuzzleImageDataUrl {
|
||||
mime_type: mime_type.to_string(),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_puzzle_base64(value: &str) -> Option<Vec<u8>> {
|
||||
let cleaned = value.trim().replace(char::is_whitespace, "");
|
||||
let mut output = Vec::with_capacity(cleaned.len() * 3 / 4);
|
||||
let mut buffer = 0u32;
|
||||
let mut bits = 0u8;
|
||||
|
||||
for byte in cleaned.bytes() {
|
||||
let value = match byte {
|
||||
b'A'..=b'Z' => byte - b'A',
|
||||
b'a'..=b'z' => byte - b'a' + 26,
|
||||
b'0'..=b'9' => byte - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
b'=' => break,
|
||||
_ => return None,
|
||||
} as u32;
|
||||
buffer = (buffer << 6) | value;
|
||||
bits += 6;
|
||||
while bits >= 8 {
|
||||
bits -= 8;
|
||||
output.push(((buffer >> bits) & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
Some(output)
|
||||
}
|
||||
|
||||
fn extract_puzzle_task_id(payload: &Value) -> Option<String> {
|
||||
find_first_puzzle_string_by_key(payload, "task_id")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user