Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/assets.rs
T
AIGameCreator App 7afec12958 拆分游戏创作壳 Tauri 入口
拆出 CLI、命令、配置、Agent、素材、项目、预览和窗口模块
拆出 Rust 单测并保留 main.rs 作为 Tauri 薄入口
扩展壳配置与原生壳门禁的 Rust 源码扫描范围
同步 AI 游戏创作 App 技术方案和决策记录
2026-07-04 16:25:45 +08:00

779 lines
26 KiB
Rust

use super::*;
pub(crate) fn upload_local_asset_at(
root: &Path,
file_name: &str,
media_type: &str,
bytes: &[u8],
) -> Result<UploadLocalAssetResult, String> {
if bytes.is_empty() {
return Err("上传文件不能为空".to_string());
}
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let safe_name = sanitize_file_name(file_name);
let asset_id = format!("upload-{}", unix_millis());
let relative_path = format!("assets/uploads/{asset_id}-{safe_name}");
let absolute_path = root.join(&relative_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建上传目录失败:{}: {error}", parent.display()))?;
}
fs::write(&absolute_path, bytes)
.map_err(|error| format!("写入上传文件失败:{}: {error}", absolute_path.display()))?;
register_local_asset_entry(
root,
&relative_path,
"uploaded",
media_type,
"upload",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
},
)
}
pub(crate) fn register_local_asset_at(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
source_kind: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
let absolute_path = resolve_local_project_path(root, local_path)?;
let metadata = fs::metadata(&absolute_path)
.map_err(|error| format!("读取资产文件失败:{}: {error}", absolute_path.display()))?;
if !metadata.is_file() {
return Err("只能登记文件资产".to_string());
}
let id_prefix = if source_kind.is_empty() {
"generated"
} else {
source_kind
};
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
}
pub(crate) fn import_canvas_asset_at(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
canvas_project_id: &str,
resource_id: Option<String>,
asset_object_id: Option<String>,
task_id: Option<String>,
prompt: Option<String>,
model: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
if canvas_project_id.is_empty() {
return Err("画板项目 ID 不能为空".to_string());
}
if resource_id.is_none() && asset_object_id.is_none() {
return Err("画板资源 ID 和资产对象 ID 至少需要一个".to_string());
}
register_local_asset_at(
root,
local_path,
kind,
media_type,
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(canvas_project_id.to_string()),
resource_id,
asset_object_id,
task_id,
prompt,
model,
},
)
}
pub(crate) fn import_canvas_export_at(
root: &Path,
export_path: &Path,
canvas_project_id: &str,
) -> Result<ImportCanvasExportResult, String> {
if canvas_project_id.is_empty() {
return Err("画板项目 ID 不能为空".to_string());
}
if export_path.as_os_str().is_empty() {
return Err("画板导出 ZIP 路径不能为空".to_string());
}
if !export_path.is_absolute() {
return Err("画板导出 ZIP 路径必须是绝对路径".to_string());
}
let metadata = fs::symlink_metadata(export_path)
.map_err(|error| format!("读取画板导出 ZIP 失败:{}: {error}", export_path.display()))?;
if metadata.file_type().is_symlink() {
return Err("画板导出 ZIP 不能是符号链接".to_string());
}
if !metadata.is_file() {
return Err("画板导出路径必须是 ZIP 文件".to_string());
}
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let source = File::open(export_path)
.map_err(|error| format!("打开画板导出 ZIP 失败:{}: {error}", export_path.display()))?;
let mut archive =
zip::ZipArchive::new(source).map_err(|error| format!("读取画板导出 ZIP 失败:{error}"))?;
let (metadata_entry, export_metadata) = read_canvas_export_metadata_from_zip(&mut archive)?;
let zip_root_prefix = metadata_entry
.strip_suffix("metadata.json")
.unwrap_or_default()
.to_string();
let source_stem = export_path
.file_stem()
.and_then(|value| value.to_str())
.map(sanitize_file_name)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "canvas-export".to_string());
let import_relative_root = format!("assets/canvas-imports/{source_stem}-{}", unix_millis());
let copied_files = extract_canvas_export_zip_files(
root,
&mut archive,
&zip_root_prefix,
&import_relative_root,
)?;
let mut assets = Vec::new();
for layer in &export_metadata.layers {
if layer.export_error.is_some() {
continue;
}
let Some(file) = layer
.file
.as_deref()
.map(str::trim)
.filter(|file| !file.is_empty())
else {
continue;
};
let file = normalize_relative_path(file)?;
if !copied_files.contains(&file) {
continue;
}
let local_path = format!("{import_relative_root}/{file}");
let asset_object_id =
if layer.visible.object.trim().is_empty() || layer.visible.object.trim() == "-" {
format!("canvas-export:{file}")
} else {
layer.visible.object.trim().to_string()
};
let task_id = if layer.visible.task.trim().is_empty() || layer.visible.task.trim() == "-" {
None
} else {
Some(layer.visible.task.trim().to_string())
};
let model = if layer.visible.model.trim().is_empty() || layer.visible.model.trim() == "-" {
None
} else {
Some(layer.visible.model.trim().to_string())
};
assets.push(register_local_asset_entry(
root,
&local_path,
infer_canvas_export_asset_kind(layer, &file),
infer_canvas_export_media_type(&file),
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(canvas_project_id.to_string()),
resource_id: None,
asset_object_id: Some(asset_object_id),
task_id,
prompt: Some(layer.title.trim().to_string()).filter(|value| !value.is_empty()),
model,
},
)?);
}
let metadata_path = format!("{import_relative_root}/metadata.json");
append_agent_db_record(
root,
serde_json::json!({
"recordType": "canvas.export_import",
"canvasProjectId": canvas_project_id,
"projectTitle": export_metadata.project_title,
"exportedAt": export_metadata.exported_at,
"importRoot": import_relative_root,
"metadataPath": metadata_path,
"importedCount": assets.len(),
}),
)?;
Ok(ImportCanvasExportResult {
import_root: import_relative_root,
metadata_path,
imported_count: assets.len(),
assets,
})
}
pub(crate) async fn sync_canvas_project_assets_at(
root: &Path,
canvas_project_id: &str,
api_base_url: Option<String>,
api_key: Option<String>,
) -> Result<SyncCanvasProjectAssetsResult, String> {
let canvas_project_id = canvas_project_id.trim();
if canvas_project_id.is_empty() {
return Err("画板项目 ID 不能为空".to_string());
}
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let api_base_url = resolve_canvas_sync_api_base_url(api_base_url)?;
let api_key = resolve_canvas_sync_api_key(api_key)?;
let client = reqwest::Client::new();
let project_url = format!(
"{}/api/external/v1/editor/projects/{}",
api_base_url,
percent_encode_query_component(canvas_project_id)
);
let project_response = client
.get(project_url)
.bearer_auth(&api_key)
.send()
.await
.map_err(|error| format!("读取画板项目失败:{error}"))?;
let project_status = project_response.status();
if !project_status.is_success() {
return Err(format!(
"读取画板项目失败:HTTP {}",
project_status.as_u16()
));
}
let project_payload = project_response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析画板项目失败:{error}"))?;
let project = project_payload
.get("project")
.or_else(|| project_payload.pointer("/data/project"))
.ok_or_else(|| "画板项目响应缺少 project".to_string())?;
let resources = project
.get("resources")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "画板项目响应缺少 resources".to_string())?;
let import_root = format!(
"assets/canvas-sync/{}-{}",
sanitize_file_name(canvas_project_id),
unix_millis()
);
let mut assets = Vec::new();
for resource in resources {
let Some(resource_id) = json_string_field(resource, "resourceId") else {
continue;
};
let Some(download) =
resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource).await?
else {
continue;
};
let extension = infer_file_extension(
json_string_field(resource, "objectKey")
.or_else(|| json_string_field(resource, "imageSrc"))
.as_deref(),
&download.media_type,
);
let local_path = format!(
"{}/{}.{}",
import_root,
sanitize_file_name(&resource_id),
extension
);
let absolute_path = root.join(&local_path);
if let Some(parent) = absolute_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建画板同步目录失败:{}: {error}", parent.display()))?;
}
fs::write(&absolute_path, &download.bytes).map_err(|error| {
format!("写入画板同步资产失败:{}: {error}", absolute_path.display())
})?;
assets.push(register_local_asset_entry(
root,
&local_path,
json_string_field(resource, "assetKind")
.as_deref()
.unwrap_or("canvas-resource"),
&download.media_type,
"canvas",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some(canvas_project_id.to_string()),
resource_id: Some(resource_id),
asset_object_id: json_string_field(resource, "assetObjectId"),
task_id: json_string_field(resource, "taskId"),
prompt: json_string_field(resource, "actualPrompt")
.or_else(|| json_string_field(resource, "prompt")),
model: json_string_field(resource, "model"),
},
)?);
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": "canvas.project_sync",
"canvasProjectId": canvas_project_id,
"importRoot": import_root,
"importedCount": assets.len(),
}),
)?;
Ok(SyncCanvasProjectAssetsResult {
canvas_project_id: canvas_project_id.to_string(),
import_root,
imported_count: assets.len(),
assets,
})
}
pub(crate) struct CanvasResourceDownload {
pub(crate) bytes: Vec<u8>,
pub(crate) media_type: String,
}
pub(crate) async fn resolve_canvas_resource_download(
client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
resource: &serde_json::Value,
) -> Result<Option<CanvasResourceDownload>, String> {
let signed_url = if let Some(object_key) = json_string_field(resource, "objectKey") {
let read_url = format!(
"{}/api/external/v1/assets/read-url?objectKey={}",
api_base_url,
percent_encode_query_component(&object_key)
);
Some(resolve_external_asset_signed_url(client, api_key, read_url).await?)
} else if let Some(image_src) = json_string_field(resource, "imageSrc") {
if image_src.starts_with('/') {
let read_url = format!(
"{}/api/external/v1/assets/read-url?legacyPublicPath={}",
api_base_url,
percent_encode_query_component(&image_src)
);
Some(resolve_external_asset_signed_url(client, api_key, read_url).await?)
} else if image_src.starts_with("http://") || image_src.starts_with("https://") {
Some(image_src)
} else {
None
}
} else {
None
};
let Some(url) = signed_url else {
return Ok(None);
};
let response = client
.get(url)
.send()
.await
.map_err(|error| format!("下载画板资产失败:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("下载画板资产失败:HTTP {}", status.as_u16()));
}
if response
.content_length()
.is_some_and(|size| size > 20 * 1024 * 1024)
{
return Err("画板资产超过 20 MiB,已拒绝同步".to_string());
}
let media_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|error| format!("读取画板资产失败:{error}"))?;
if bytes.len() > 20 * 1024 * 1024 {
return Err("画板资产超过 20 MiB,已拒绝同步".to_string());
}
if bytes.is_empty() {
return Ok(None);
}
Ok(Some(CanvasResourceDownload {
bytes: bytes.to_vec(),
media_type,
}))
}
pub(crate) async fn resolve_external_asset_signed_url(
client: &reqwest::Client,
api_key: &str,
read_url: String,
) -> Result<String, String> {
let response = client
.get(read_url)
.bearer_auth(api_key)
.send()
.await
.map_err(|error| format!("换签画板资产失败:{error}"))?;
let status = response.status();
if !status.is_success() {
return Err(format!("换签画板资产失败:HTTP {}", status.as_u16()));
}
let payload = response
.json::<serde_json::Value>()
.await
.map_err(|error| format!("解析画板资产签名失败:{error}"))?;
json_string_field(
payload
.get("read")
.or_else(|| payload.pointer("/data/read"))
.unwrap_or(&serde_json::Value::Null),
"signedUrl",
)
.ok_or_else(|| "画板资产签名响应缺少 signedUrl".to_string())
}
pub(crate) fn resolve_canvas_sync_api_base_url(
api_base_url: Option<String>,
) -> Result<String, String> {
let config = load_game_creator_app_config()?;
let value = trim_optional_string(api_base_url)
.or_else(|| trim_config_string(&config.editor_api.base_url))
.unwrap_or_else(|| DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string());
let value = value.trim().trim_end_matches('/').to_string();
if value.starts_with("http://") || value.starts_with("https://") {
Ok(value)
} else {
Err("画板 API base_url 必须是 http(s) URL".to_string())
}
}
pub(crate) fn resolve_canvas_sync_api_key(api_key: Option<String>) -> Result<String, String> {
let config = load_game_creator_app_config()?;
trim_optional_string(api_key)
.or_else(|| trim_config_string(&config.editor_api.api_key))
.ok_or_else(|| {
format!(
"画板同步需要在 {} 的 editorApi.apiKey 中设置 API Key",
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
)
})
}
pub(crate) fn json_string_field(value: &serde_json::Value, field: &str) -> Option<String> {
value
.get(field)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
pub(crate) fn percent_encode_query_component(value: &str) -> String {
value
.bytes()
.flat_map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
vec![byte as char]
}
_ => format!("%{byte:02X}").chars().collect(),
})
.collect()
}
pub(crate) fn infer_file_extension(source: Option<&str>, media_type: &str) -> &'static str {
if let Some(source) = source {
let path = source.split('?').next().unwrap_or(source);
if path.ends_with(".png") {
return "png";
}
if path.ends_with(".jpg") || path.ends_with(".jpeg") {
return "jpg";
}
if path.ends_with(".webp") {
return "webp";
}
if path.ends_with(".gif") {
return "gif";
}
if path.ends_with(".mp3") {
return "mp3";
}
if path.ends_with(".wav") {
return "wav";
}
if path.ends_with(".mp4") {
return "mp4";
}
}
match media_type.split(';').next().unwrap_or(media_type).trim() {
"image/png" => "png",
"image/jpeg" => "jpg",
"image/webp" => "webp",
"image/gif" => "gif",
"audio/mpeg" => "mp3",
"audio/wav" | "audio/x-wav" => "wav",
"video/mp4" => "mp4",
_ => "bin",
}
}
pub(crate) fn read_canvas_export_metadata_from_zip(
archive: &mut zip::ZipArchive<File>,
) -> Result<(String, CanvasExportMetadata), String> {
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取画板导出 ZIP 条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let name = normalized_zip_entry_name(entry.name())?;
if !name.ends_with("metadata.json") {
continue;
}
let mut content = String::new();
entry
.read_to_string(&mut content)
.map_err(|error| format!("读取画板导出 metadata.json 失败:{error}"))?;
if let Ok(metadata) = serde_json::from_str::<CanvasExportMetadata>(&content) {
return Ok((name, metadata));
}
}
Err("画板导出 ZIP 缺少根 metadata.json".to_string())
}
pub(crate) fn extract_canvas_export_zip_files(
root: &Path,
archive: &mut zip::ZipArchive<File>,
zip_root_prefix: &str,
import_relative_root: &str,
) -> Result<Vec<String>, String> {
let mut copied_files = Vec::new();
let mut total_bytes = 0_u64;
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取画板导出 ZIP 条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let name = normalized_zip_entry_name(entry.name())?;
if !name.starts_with(zip_root_prefix) {
continue;
}
let relative_in_export = name
.strip_prefix(zip_root_prefix)
.ok_or_else(|| "画板导出 ZIP 目录结构非法".to_string())?;
if relative_in_export.is_empty() {
continue;
}
if entry.size() > MAX_CANVAS_EXPORT_BYTES {
return Err("画板导出 ZIP 单文件过大".to_string());
}
total_bytes = total_bytes
.checked_add(entry.size())
.ok_or_else(|| "画板导出 ZIP 文件过大".to_string())?;
if total_bytes > MAX_CANVAS_EXPORT_BYTES {
return Err("画板导出 ZIP 解压体积过大".to_string());
}
if copied_files.len() >= MAX_CANVAS_EXPORT_FILES {
return Err("画板导出 ZIP 文件数量过多".to_string());
}
let normalized_relative = normalize_relative_path(relative_in_export)?;
let local_relative_path = format!("{import_relative_root}/{normalized_relative}");
let target_path = resolve_local_project_path(root, &local_relative_path)?;
if let Some(parent) = target_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建画板导入目录失败:{}: {error}", parent.display()))?;
}
let mut output = File::create(&target_path)
.map_err(|error| format!("写入画板导入文件失败:{}: {error}", target_path.display()))?;
std::io::copy(&mut entry, &mut output)
.map_err(|error| format!("解压画板导出文件失败:{}: {error}", target_path.display()))?;
copied_files.push(normalized_relative);
}
if copied_files.is_empty() {
return Err("画板导出 ZIP 没有可导入文件".to_string());
}
Ok(copied_files)
}
pub(crate) fn normalized_zip_entry_name(name: &str) -> Result<String, String> {
if name.contains('\\') || name.contains(':') || name.starts_with('/') {
return Err("画板导出 ZIP 条目路径非法".to_string());
}
let normalized = name.trim_matches('/').to_string();
if normalized.is_empty() {
return Err("画板导出 ZIP 条目路径为空".to_string());
}
normalize_relative_path(&normalized)
}
pub(crate) fn infer_canvas_export_asset_kind(
layer: &CanvasExportLayerMetadata,
file: &str,
) -> &'static str {
let layer_type = layer.visible.layer_type.as_str();
if file.starts_with("sequences/") || contains_any(layer_type, &["序列", "动画", "动作"]) {
return "animation";
}
if file.starts_with("media/") || contains_any(layer_type, &["音频", "音乐", "音效"]) {
return "audio";
}
if contains_any(layer_type, &["角色"]) {
return "character";
}
if contains_any(layer_type, &["场景", "背景"]) {
return "scene";
}
if contains_any(layer_type, &["UI", "界面", "图标"]) {
return "ui";
}
"asset"
}
pub(crate) fn infer_canvas_export_media_type(file: &str) -> &'static str {
if file.starts_with("sequences/") {
return "application/vnd.genarrative.image-sequence";
}
match Path::new(file)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| extension.to_ascii_lowercase())
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
Some("mp3") => "audio/mpeg",
Some("wav") => "audio/wav",
Some("ogg") => "audio/ogg",
Some("aac") => "audio/aac",
Some("flac") => "audio/flac",
Some("m4a") => "audio/mp4",
Some("mp4") => "video/mp4",
Some("webm") => "video/webm",
Some("mov") => "video/quicktime",
Some("json") => "application/json",
Some("txt") => "text/plain",
_ => "application/octet-stream",
}
}
pub(crate) fn build_canvas_project_url(
editor_base_url: Option<&str>,
canvas_project_id: Option<&str>,
) -> Result<String, String> {
let base_url = editor_base_url
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_EDITOR_BASE_URL);
let mut url =
tauri::Url::parse(base_url).map_err(|_| "画板地址必须是本地 HTTP 地址".to_string())?;
if url.scheme() != "http" {
return Err("画板地址必须使用本地 HTTP".to_string());
}
match url.host_str() {
Some("127.0.0.1" | "localhost") => {}
_ => return Err("画板地址只能指向本机编辑器".to_string()),
}
url.set_path("/editor/canvas");
url.set_query(None);
url.set_fragment(None);
if let Some(project_id) = canvas_project_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
if project_id.chars().any(char::is_control) {
return Err("画板项目 ID 不能包含控制字符".to_string());
}
url.query_pairs_mut()
.append_pair("projectid", project_id)
.finish();
}
Ok(url.to_string())
}
pub(crate) fn register_local_asset_entry(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
let kind = if kind.is_empty() { "asset" } else { kind };
let media_type = if media_type.is_empty() {
"application/octet-stream"
} else {
media_type
};
let source_for_record = source.clone();
let (id, record_type) = if let Some(existing) = manifest
.assets
.iter_mut()
.find(|asset| asset.local_path == normalized_path)
{
existing.kind = kind.to_string();
existing.media_type = media_type.to_string();
existing.source = source;
(existing.id.clone(), "asset.update")
} else {
let id = format!(
"{id_prefix}-{}-{}",
unix_millis(),
manifest.assets.len() + 1
);
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: id.clone(),
kind: kind.to_string(),
media_type: media_type.to_string(),
local_path: normalized_path.clone(),
source,
});
(id, "asset.register")
};
write_manifest(&manifest_path, &manifest)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
}),
)?;
Ok(UploadLocalAssetResult {
id,
local_path: normalized_path.clone(),
absolute_path: absolute_path.to_string_lossy().into_owned(),
manifest_path: manifest_path.to_string_lossy().into_owned(),
})
}