7afec12958
拆出 CLI、命令、配置、Agent、素材、项目、预览和窗口模块 拆出 Rust 单测并保留 main.rs 作为 Tauri 薄入口 扩展壳配置与原生壳门禁的 Rust 源码扫描范围 同步 AI 游戏创作 App 技术方案和决策记录
779 lines
26 KiB
Rust
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(),
|
|
})
|
|
}
|