puzzle单机实现

This commit is contained in:
2026-04-24 20:41:24 +08:00
parent 88bd4f36e9
commit 0940a1945c
6 changed files with 716 additions and 152 deletions

View File

@@ -1,7 +1,6 @@
use std::{
env, fs,
path::{Path, PathBuf},
time::{SystemTime, UNIX_EPOCH},
collections::BTreeMap,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use axum::{
@@ -13,7 +12,12 @@ use axum::{
sse::{Event, Sse},
},
};
use serde_json::{Value, json};
use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use serde_json::{Map, Value, json};
use shared_contracts::{
puzzle_agent::{
CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest,
@@ -50,6 +54,7 @@ use spacetime_client::{
SpacetimeClientError,
};
use std::convert::Infallible;
use tokio::time::sleep;
use crate::{
api_response::json_success_body,
@@ -67,6 +72,10 @@ 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_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>,
@@ -438,12 +447,15 @@ pub async fn execute_puzzle_agent_action(
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| draft.summary.clone());
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
let candidates = build_placeholder_puzzle_candidates(
let candidates = generate_puzzle_image_candidates(
&state,
owner_user_id.as_str(),
&session.session_id,
&draft.level_name,
&prompt,
candidate_count,
)
.await
.map_err(SpacetimeClientError::Runtime);
match candidates {
Ok(candidates) => {
@@ -1380,27 +1392,47 @@ fn puzzle_sse_error_event_message(message: String) -> Event {
Event::default().event("error").data(payload)
}
fn build_placeholder_puzzle_candidates(
async fn generate_puzzle_image_candidates(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
prompt: &str,
candidate_count: u32,
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
let count = candidate_count.clamp(1, 2);
let mut items = Vec::with_capacity(count as usize);
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
.map_err(|error| error.message().to_string())?;
let mut items = Vec::with_capacity(generated.images.len());
for index in 0..count {
let asset = save_placeholder_puzzle_asset(
for (index, image) in generated.images.into_iter().enumerate() {
let candidate_id = format!("{session_id}-candidate-{}", index + 1);
let asset = persist_puzzle_generated_asset(
state,
owner_user_id,
session_id,
level_name,
&format!("candidate-{}", index + 1),
"cover",
"1536*1536",
Some(prompt),
candidate_id.as_str(),
generated.task_id.as_str(),
image,
current_utc_micros(),
)
.await
.map_err(|error| error.message().to_string())?;
items.push(PuzzleGeneratedImageCandidateResponse {
candidate_id: format!("{session_id}-candidate-{}", index + 1),
candidate_id,
image_src: asset.image_src,
asset_id: asset.asset_id,
prompt: prompt.to_string(),
@@ -1424,98 +1456,452 @@ fn build_placeholder_puzzle_candidates(
.collect())
}
struct PuzzleDashScopeSettings {
base_url: String,
api_key: String,
request_timeout_ms: u64,
}
struct PuzzleGeneratedImages {
task_id: String,
images: Vec<PuzzleDownloadedImage>,
}
struct PuzzleDownloadedImage {
extension: String,
mime_type: String,
bytes: Vec<u8>,
}
struct GeneratedPuzzleAssetResponse {
image_src: String,
asset_id: String,
}
fn save_placeholder_puzzle_asset(
session_segment_seed: &str,
work_segment_seed: &str,
leaf_segment_seed: &str,
file_stem: &str,
fn require_puzzle_dashscope_settings(state: &AppState) -> Result<PuzzleDashScopeSettings, AppError> {
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_BASE_URL 未配置",
})));
}
let api_key = state
.config
.dashscope_api_key
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "dashscope",
"reason": "DASHSCOPE_API_KEY 未配置",
}))
})?;
Ok(PuzzleDashScopeSettings {
base_url: base_url.to_string(),
api_key: api_key.to_string(),
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
})
}
fn build_puzzle_dashscope_http_client(
settings: &PuzzleDashScopeSettings,
) -> Result<reqwest::Client, AppError> {
reqwest::Client::builder()
.timeout(Duration::from_millis(settings.request_timeout_ms))
.build()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "dashscope",
"message": format!("构造拼图 DashScope HTTP 客户端失败:{error}"),
}))
})
}
async fn create_puzzle_text_to_image_generation(
http_client: &reqwest::Client,
settings: &PuzzleDashScopeSettings,
prompt: &str,
negative_prompt: &str,
size: &str,
prompt: Option<&str>,
candidate_count: u32,
) -> Result<PuzzleGeneratedImages, AppError> {
let mut parameters = Map::from_iter([
("n".to_string(), json!(candidate_count.clamp(1, 2))),
("size".to_string(), Value::String(size.to_string())),
("prompt_extend".to_string(), Value::Bool(true)),
("watermark".to_string(), Value::Bool(false)),
]);
parameters.insert(
"negative_prompt".to_string(),
Value::String(negative_prompt.to_string()),
);
let response = http_client
.post(format!(
"{}/services/aigc/text2image/image-synthesis",
settings.base_url
))
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", settings.api_key),
)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.header("X-DashScope-Async", "enable")
.json(&json!({
"model": PUZZLE_TEXT_TO_IMAGE_MODEL,
"input": { "prompt": prompt },
"parameters": parameters,
}))
.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 task_id = extract_puzzle_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": "拼图图片生成任务未返回 task_id",
}))
})?;
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, 2) as usize) {
images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?);
}
return Ok(PuzzleGeneratedImages { task_id, images });
}
if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") {
return Err(map_puzzle_dashscope_upstream_error(
poll_text.as_str(),
"拼图图片生成任务失败",
));
}
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,
) -> Result<PuzzleDownloadedImage, AppError> {
let response = http_client.get(image_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/jpeg")
.to_string();
let bytes = 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": "dashscope",
"message": "下载拼图正式图片失败",
"status": status.as_u16(),
})));
}
let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str());
Ok(PuzzleDownloadedImage {
extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(),
mime_type,
bytes: bytes.to_vec(),
})
}
async fn persist_puzzle_generated_asset(
state: &AppState,
owner_user_id: &str,
session_id: &str,
level_name: &str,
candidate_id: &str,
task_id: &str,
image: PuzzleDownloadedImage,
generated_at_micros: i64,
) -> Result<GeneratedPuzzleAssetResponse, AppError> {
let asset_id = format!("{file_stem}-{}", current_utc_millis());
let relative_dir = PathBuf::from("generated-puzzle-covers")
.join(sanitize_path_segment(session_segment_seed, "session"))
.join(sanitize_path_segment(work_segment_seed, "puzzle"))
.join(sanitize_path_segment(leaf_segment_seed, "candidate"))
.join(&asset_id);
let output_dir = resolve_public_output_dir(&relative_dir)?;
fs::create_dir_all(&output_dir).map_err(io_error)?;
let file_name = format!("{file_stem}.svg");
let svg = build_puzzle_placeholder_svg(size, prompt.unwrap_or(file_stem));
fs::write(output_dir.join(&file_name), svg).map_err(io_error)?;
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let http_client = reqwest::Client::new();
let asset_id = format!("asset-{generated_at_micros}");
let put_result = oss_client
.put_object(
&http_client,
OssPutObjectRequest {
prefix: LegacyAssetPrefix::PuzzleAssets,
path_segments: vec![
sanitize_path_segment(session_id, "session"),
sanitize_path_segment(level_name, "puzzle"),
sanitize_path_segment(candidate_id, "candidate"),
asset_id.clone(),
],
file_name: format!("image.{}", image.extension),
content_type: Some(image.mime_type.clone()),
access: OssObjectAccess::Private,
metadata: build_puzzle_asset_metadata(owner_user_id, session_id, candidate_id),
body: image.bytes,
},
)
.await
.map_err(map_puzzle_asset_oss_error)?;
let head = oss_client
.head_object(
&http_client,
OssHeadObjectRequest {
object_key: put_result.object_key.clone(),
},
)
.await
.map_err(map_puzzle_asset_oss_error)?;
let asset_object = state
.spacetime_client()
.confirm_asset_object(
build_asset_object_upsert_input(
generate_asset_object_id(generated_at_micros),
head.bucket,
head.object_key,
AssetObjectAccessPolicy::Private,
head.content_type.or(Some(image.mime_type)),
head.content_length,
head.etag,
"puzzle_cover_image".to_string(),
Some(task_id.to_string()),
Some(owner_user_id.to_string()),
None,
Some(session_id.to_string()),
generated_at_micros,
)
.map_err(map_puzzle_asset_field_error)?,
)
.await
.map_err(map_puzzle_asset_spacetime_error)?;
state
.spacetime_client()
.bind_asset_object_to_entity(
build_asset_entity_binding_input(
generate_asset_binding_id(generated_at_micros),
asset_object.asset_object_id,
PUZZLE_ENTITY_KIND.to_string(),
session_id.to_string(),
candidate_id.to_string(),
"puzzle_cover_image".to_string(),
Some(owner_user_id.to_string()),
None,
generated_at_micros,
)
.map_err(map_puzzle_asset_field_error)?,
)
.await
.map_err(map_puzzle_asset_spacetime_error)?;
Ok(GeneratedPuzzleAssetResponse {
image_src: format!(
"/{}/{}",
relative_dir.to_string_lossy().replace('\\', "/"),
file_name
),
image_src: put_result.legacy_public_path,
asset_id,
})
}
fn build_puzzle_placeholder_svg(size: &str, label: &str) -> String {
let (width, height) = parse_size(size);
fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#201a0f"/>
<stop offset="50%" stop-color="#4a2c24"/>
<stop offset="100%" stop-color="#10243a"/>
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#bg)"/>
<circle cx="{cx1}" cy="{cy1}" r="{r1}" fill="rgba(255,244,214,0.12)"/>
<circle cx="{cx2}" cy="{cy2}" r="{r2}" fill="rgba(115,194,255,0.14)"/>
<rect x="{frame_x}" y="{frame_y}" width="{frame_w}" height="{frame_h}" rx="{frame_r}" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.18)"/>
<text x="50%" y="47%" text-anchor="middle" fill="#f6e9cf" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
<text x="50%" y="57%" text-anchor="middle" fill="#d4e8ff" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">Puzzle placeholder</text>
</svg>"##,
width = width,
height = height,
cx1 = width / 4,
cy1 = height / 3,
r1 = (width.min(height) / 5).max(42),
cx2 = width * 3 / 4,
cy2 = height / 4,
r2 = (width.min(height) / 7).max(30),
frame_x = width / 9,
frame_y = height / 9,
frame_w = width * 7 / 9,
frame_h = height * 7 / 9,
frame_r = (width.min(height) / 20).max(18),
font_main = (width.min(height) / 14).max(22),
font_sub = (width.min(height) / 30).max(12),
title = escape_svg_text(label),
"生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。"
)
}
fn parse_size(size: &str) -> (u32, u32) {
let mut parts = size.split('*');
let width = parts
.next()
.and_then(|value| value.trim().parse::<u32>().ok())
.filter(|value| *value > 0)
.unwrap_or(1536);
let height = parts
.next()
.and_then(|value| value.trim().parse::<u32>().ok())
.filter(|value| *value > 0)
.unwrap_or(1536);
(width, height)
fn build_puzzle_asset_metadata(
owner_user_id: &str,
session_id: &str,
candidate_id: &str,
) -> BTreeMap<String, String> {
BTreeMap::from([
("asset_kind".to_string(), "puzzle_cover_image".to_string()),
("owner_user_id".to_string(), owner_user_id.to_string()),
("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()),
("entity_id".to_string(), session_id.to_string()),
("slot".to_string(), candidate_id.to_string()),
])
}
fn escape_svg_text(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result<Value, AppError> {
serde_json::from_str::<Value>(raw_text).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": format!("{fallback_message}{error}"),
}))
})
}
fn extract_puzzle_task_id(payload: &Value) -> Option<String> {
find_first_puzzle_string_by_key(payload, "task_id")
}
fn extract_puzzle_image_urls(payload: &Value) -> Vec<String> {
let mut urls = Vec::new();
collect_puzzle_strings_by_key(payload, "image", &mut urls);
collect_puzzle_strings_by_key(payload, "url", &mut urls);
let mut deduped = Vec::new();
for url in urls {
if !deduped.contains(&url) {
deduped.push(url);
}
}
deduped
}
fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option<String> {
let mut results = Vec::new();
collect_puzzle_strings_by_key(payload, target_key, &mut results);
results.into_iter().next()
}
fn collect_puzzle_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec<String>) {
match payload {
Value::Array(entries) => {
for entry in entries {
collect_puzzle_strings_by_key(entry, target_key, results);
}
}
Value::Object(object) => {
for (key, value) in object {
if key == target_key
&& let Some(text) = value.as_str()
{
results.push(text.to_string());
}
collect_puzzle_strings_by_key(value, target_key, results);
}
}
_ => {}
}
}
fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String {
let mime_type = content_type
.split(';')
.next()
.map(str::trim)
.unwrap_or("image/jpeg");
match mime_type {
"image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => {
mime_type.to_string()
}
_ => "image/jpeg".to_string(),
}
}
fn puzzle_mime_to_extension(mime_type: &str) -> &str {
match mime_type {
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => "jpg",
}
}
fn map_puzzle_dashscope_request_error(message: String) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": message,
}))
}
fn map_puzzle_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
"message": parse_puzzle_api_error_message(raw_text, fallback_message),
}))
}
fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String {
let trimmed = raw_text.trim();
if trimmed.is_empty() {
return fallback_message.to_string();
}
if let Ok(payload) = serde_json::from_str::<Value>(trimmed)
&& let Some(message) = find_first_puzzle_string_by_key(&payload, "message")
{
return message;
}
fallback_message.to_string()
}
fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "aliyun-oss",
"message": error.to_string(),
}))
}
fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": error.to_string(),
}))
}
fn map_puzzle_asset_field_error(error: AssetObjectFieldError) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "asset-object",
"message": error.to_string(),
}))
}
fn sanitize_path_segment(value: &str, fallback: &str) -> String {
@@ -1539,31 +1925,9 @@ fn sanitize_path_segment(value: &str, fallback: &str) -> String {
}
}
fn resolve_public_output_dir(relative_dir: &Path) -> Result<PathBuf, AppError> {
let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(3)
.ok_or_else(|| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message("无法定位仓库根目录")
})?;
Ok(workspace_root.join("public").join(relative_dir))
}
fn io_error(error: std::io::Error) -> AppError {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
fn current_utc_micros() -> i64 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
(duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros())
}
fn current_utc_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}

View File

@@ -40,6 +40,7 @@ pub enum LegacyAssetPrefix {
Characters,
Animations,
BigFishAssets,
PuzzleAssets,
CustomWorldScenes,
CustomWorldCovers,
QwenSprites,
@@ -209,6 +210,7 @@ impl LegacyAssetPrefix {
"generated-characters" => Some(Self::Characters),
"generated-animations" => Some(Self::Animations),
"generated-big-fish-assets" => Some(Self::BigFishAssets),
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
"generated-qwen-sprites" => Some(Self::QwenSprites),
@@ -222,6 +224,7 @@ impl LegacyAssetPrefix {
Self::Characters => "generated-characters",
Self::Animations => "generated-animations",
Self::BigFishAssets => "generated-big-fish-assets",
Self::PuzzleAssets => "generated-puzzle-assets",
Self::CustomWorldScenes => "generated-custom-world-scenes",
Self::CustomWorldCovers => "generated-custom-world-covers",
Self::QwenSprites => "generated-qwen-sprites",