Feat: 完善 Game Agent 资源画板与图片精修链路 #176
@@ -64,6 +64,7 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
|
||||
];
|
||||
const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art";
|
||||
const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [
|
||||
("game/index.html", "game-entry", "text/html"),
|
||||
("game/style.css", "game-style", "text/css"),
|
||||
@@ -1872,7 +1873,7 @@ fn direct_taonier_art_generation_runtime_context(
|
||||
_ => return Err("直连美术生成请求包含未声明的输出路径".to_string()),
|
||||
};
|
||||
Ok(PlatformArtGenerationRuntimeContext {
|
||||
agent_id: "direct-codex-art".to_string(),
|
||||
agent_id: DIRECT_CODEX_ART_AGENT_ID.to_string(),
|
||||
task_id: format!("direct-codex-art-{stage}"),
|
||||
session_id: project_id,
|
||||
run_id: stage.to_string(),
|
||||
@@ -2607,6 +2608,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at(
|
||||
json_string_field(resource, "objectKey").as_deref(),
|
||||
&asset_object_id,
|
||||
)?;
|
||||
emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID);
|
||||
emit_direct_game_creator_progress(
|
||||
root,
|
||||
"art.spritesheet.recovered",
|
||||
@@ -2744,6 +2746,7 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
}
|
||||
Err(error) => return Err(direct_taonier_art_generation_failure(asset_label, error)),
|
||||
};
|
||||
emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID);
|
||||
if generated.asset.local_path != output_path {
|
||||
return Err(format!(
|
||||
"陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成"
|
||||
|
||||
@@ -261,10 +261,11 @@ pub(crate) use entrypoints::{
|
||||
chat_with_game_creator_role_agent_stream_at,
|
||||
chat_with_game_creator_role_agent_stream_for_session_at,
|
||||
configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress,
|
||||
emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event,
|
||||
generate_local_game_draft_at, install_game_creator_manifest_invalidation_event_sink,
|
||||
read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at,
|
||||
read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle,
|
||||
emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated,
|
||||
game_creator_agent_runtime_update_event, generate_local_game_draft_at,
|
||||
install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at,
|
||||
read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at,
|
||||
set_game_creator_agent_runtime_update_app_handle,
|
||||
start_game_creator_manifest_invalidation_event_sink,
|
||||
validate_game_creator_manifest_invalidation_event_sink,
|
||||
};
|
||||
|
||||
@@ -235,6 +235,18 @@ fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Resu
|
||||
.map_err(|error| format!("发送 manifest 失效事件失败:{error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str) {
|
||||
let event = GameCreatorManifestInvalidatedEvent {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
agent_id: agent_id.to_string(),
|
||||
};
|
||||
if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() {
|
||||
let _ = app.emit("game-creator-manifest-invalidated", event);
|
||||
return;
|
||||
}
|
||||
let _ = relay_game_creator_manifest_invalidation(root, agent_id);
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_agent_runtime_update_event(
|
||||
root: &Path,
|
||||
runtime: AgentRuntimeResult,
|
||||
|
||||
@@ -623,6 +623,45 @@ pub(crate) fn update_local_project_asset_canvas_draft(
|
||||
update_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_local_project_asset_canvas_images(
|
||||
app: tauri::AppHandle,
|
||||
input: ImportAssetCanvasImagesInput,
|
||||
) -> Result<ImportAssetCanvasImagesResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
let mut dialog = app
|
||||
.dialog()
|
||||
.file()
|
||||
.set_title("导入图片")
|
||||
.add_filter("图片", &["png", "jpg", "jpeg", "webp"]);
|
||||
if let Some(window) = app.get_webview_window("client") {
|
||||
dialog = dialog.set_parent(&window);
|
||||
}
|
||||
dialog.pick_files(move |paths| {
|
||||
let _ = sender.send(paths);
|
||||
});
|
||||
let Some(paths) = receiver
|
||||
.await
|
||||
.map_err(|_| "图片文件选择器意外关闭".to_string())?
|
||||
else {
|
||||
return Ok(ImportAssetCanvasImagesResult::cancelled());
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Ok(ImportAssetCanvasImagesResult::cancelled());
|
||||
}
|
||||
let paths = paths
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
path.into_path()
|
||||
.map_err(|error| format!("读取导入图片路径失败:{error}"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths))
|
||||
.await
|
||||
.map_err(|error| format!("图片导入任务异常结束:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn store_local_project_asset_canvas_media(
|
||||
input: StoreAssetCanvasMediaInput,
|
||||
@@ -639,6 +678,14 @@ pub(crate) fn stage_local_project_asset_canvas_image(
|
||||
stage_asset_canvas_image_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn archive_failed_local_project_asset_canvas_generation(
|
||||
input: ArchiveAssetCanvasGenerationInput,
|
||||
) -> Result<ArchiveAssetCanvasGenerationResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
archive_failed_asset_canvas_generation_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn generate_local_project_asset_canvas_image(
|
||||
app: tauri::AppHandle,
|
||||
@@ -662,6 +709,14 @@ pub(crate) async fn generate_local_project_asset_canvas_image(
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn finalize_local_project_asset_canvas_generation_failure(
|
||||
input: FinalizeAssetCanvasGenerationFailureInput,
|
||||
) -> Result<FinalizeAssetCanvasGenerationFailureResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
finalize_asset_canvas_generation_failure_at(&root, &input).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn recover_local_project_asset_canvas_generations(
|
||||
app: tauri::AppHandle,
|
||||
@@ -728,6 +783,25 @@ pub(crate) fn commit_local_project_asset(
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn commit_local_project_asset_canvas_candidate(
|
||||
app: tauri::AppHandle,
|
||||
input: CommitAssetCanvasCandidateInput,
|
||||
) -> Result<CommitAssetCanvasResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
let execution = commit_asset_canvas_candidate_at(&root, &input)?;
|
||||
if let Some(event) = execution.event.as_ref() {
|
||||
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
|
||||
app.emit(
|
||||
"game-creator-local-asset-committed",
|
||||
asset_canvas_committed_public_event(payload),
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
}
|
||||
Ok(execution.result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn recover_local_project_asset_canvas_transactions(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -24,6 +24,8 @@ pub(crate) struct LocalProjectImagePreview {
|
||||
pub(crate) path: String,
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) pixel_width: u32,
|
||||
pub(crate) pixel_height: u32,
|
||||
pub(crate) data_url: String,
|
||||
}
|
||||
|
||||
@@ -32,6 +34,8 @@ pub(crate) struct AgentRuntimeInspectionImage {
|
||||
pub(crate) sha256: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) media_type: &'static str,
|
||||
pixel_width: u32,
|
||||
pixel_height: u32,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -86,6 +90,8 @@ pub(crate) fn load_local_project_image_preview_with_cancellation(
|
||||
path: image.relative_path.clone(),
|
||||
media_type: image.media_type.to_string(),
|
||||
byte_len: image.byte_len,
|
||||
pixel_width: image.pixel_width,
|
||||
pixel_height: image.pixel_height,
|
||||
data_url: image.data_url_with_cancellation(cancellation)?,
|
||||
})
|
||||
}
|
||||
@@ -360,6 +366,8 @@ fn read_agent_runtime_inspection_image_with_cancellation(
|
||||
sha256,
|
||||
byte_len: bytes.len() as u64,
|
||||
media_type,
|
||||
pixel_width: width,
|
||||
pixel_height: height,
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2366,8 +2366,11 @@ fn main() {
|
||||
read_local_project_asset_canvas_draft,
|
||||
discover_local_project_asset_canvas_draft,
|
||||
update_local_project_asset_canvas_draft,
|
||||
import_local_project_asset_canvas_images,
|
||||
store_local_project_asset_canvas_media,
|
||||
stage_local_project_asset_canvas_image,
|
||||
finalize_local_project_asset_canvas_generation_failure,
|
||||
archive_failed_local_project_asset_canvas_generation,
|
||||
generate_local_project_asset_canvas_image,
|
||||
recover_local_project_asset_canvas_generations,
|
||||
confirm_local_project_asset_canvas_generation_service_identity,
|
||||
@@ -2375,6 +2378,7 @@ fn main() {
|
||||
discard_local_project_asset_canvas_draft,
|
||||
recover_local_project_asset_canvas_transactions,
|
||||
commit_local_project_asset,
|
||||
commit_local_project_asset_canvas_candidate,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest
|
||||
])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ use std::path::Path;
|
||||
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
|
||||
const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
|
||||
const PROJECT_MEDIA_PREVIEW_MAX_DIMENSION: u32 = 8_192;
|
||||
const PROJECT_MEDIA_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -30,6 +32,10 @@ pub(crate) struct LocalProjectMediaPreview {
|
||||
pub(crate) path: String,
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) pixel_width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) pixel_height: Option<u32>,
|
||||
pub(crate) data_url: String,
|
||||
}
|
||||
|
||||
@@ -196,14 +202,135 @@ pub(crate) fn load_local_project_media_preview_with_cancellation(
|
||||
cancellation.check()?;
|
||||
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
|
||||
cancellation.check()?;
|
||||
let dimensions = (kind == ProjectMediaPreviewKind::Art)
|
||||
.then(|| detect_project_art_dimensions(&bytes, media_type))
|
||||
.flatten();
|
||||
Ok(LocalProjectMediaPreview {
|
||||
path: normalized,
|
||||
media_type: media_type.to_string(),
|
||||
byte_len: bytes.len() as u64,
|
||||
pixel_width: dimensions.map(|(width, _)| width),
|
||||
pixel_height: dimensions.map(|(_, height)| height),
|
||||
data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_project_art_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32, u32)> {
|
||||
let dimensions = match media_type {
|
||||
"image/gif" => detect_gif_dimensions(bytes),
|
||||
"image/bmp" => detect_bmp_dimensions(bytes),
|
||||
"image/avif" => detect_ispe_dimensions(bytes),
|
||||
"image/svg+xml" => detect_svg_dimensions(bytes),
|
||||
_ => None,
|
||||
}?;
|
||||
let (width, height) = dimensions;
|
||||
let pixels = u64::from(width).checked_mul(u64::from(height))?;
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION
|
||||
|| height > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION
|
||||
|| pixels > PROJECT_MEDIA_PREVIEW_MAX_PIXELS
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((width, height))
|
||||
}
|
||||
|
||||
fn detect_gif_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if bytes.len() < 10 || !matches!(&bytes[..6], b"GIF87a" | b"GIF89a") {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
u32::from(u16::from_le_bytes([bytes[6], bytes[7]])),
|
||||
u32::from(u16::from_le_bytes([bytes[8], bytes[9]])),
|
||||
))
|
||||
}
|
||||
|
||||
fn detect_bmp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if bytes.len() < 26 || &bytes[..2] != b"BM" {
|
||||
return None;
|
||||
}
|
||||
let width = i32::from_le_bytes(bytes[18..22].try_into().ok()?);
|
||||
let height = i32::from_le_bytes(bytes[22..26].try_into().ok()?);
|
||||
Some((width.unsigned_abs(), height.unsigned_abs()))
|
||||
}
|
||||
|
||||
fn detect_ispe_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let marker = b"ispe";
|
||||
let index = bytes
|
||||
.windows(marker.len())
|
||||
.position(|window| window == marker)?;
|
||||
if index < 4 || index.checked_add(16)? > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
u32::from_be_bytes(bytes[index + 8..index + 12].try_into().ok()?),
|
||||
u32::from_be_bytes(bytes[index + 12..index + 16].try_into().ok()?),
|
||||
))
|
||||
}
|
||||
|
||||
fn detect_svg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let text = std::str::from_utf8(bytes).ok()?;
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let start = lower.find("<svg")?;
|
||||
let end = lower[start..].find('>')? + start;
|
||||
let root = &text[start..end];
|
||||
let width = svg_length_attribute(root, "width");
|
||||
let height = svg_length_attribute(root, "height");
|
||||
if let (Some(width), Some(height)) = (width, height) {
|
||||
return Some((width, height));
|
||||
}
|
||||
let view_box = svg_attribute(root, "viewbox")?;
|
||||
let mut values = view_box
|
||||
.split(|character: char| character.is_ascii_whitespace() || character == ',')
|
||||
.filter_map(|value| value.parse::<f64>().ok());
|
||||
let _min_x = values.next()?;
|
||||
let _min_y = values.next()?;
|
||||
let width = values.next()?;
|
||||
let height = values.next()?;
|
||||
Some((safe_svg_dimension(width)?, safe_svg_dimension(height)?))
|
||||
}
|
||||
|
||||
fn svg_length_attribute(root: &str, name: &str) -> Option<u32> {
|
||||
let value = svg_attribute(root, name)?;
|
||||
let value = value.trim().strip_suffix("px").unwrap_or(value.trim());
|
||||
safe_svg_dimension(value.parse::<f64>().ok()?)
|
||||
}
|
||||
|
||||
fn svg_attribute<'a>(root: &'a str, name: &str) -> Option<&'a str> {
|
||||
let lower = root.to_ascii_lowercase();
|
||||
let mut offset = 0;
|
||||
while let Some(relative) = lower[offset..].find(name) {
|
||||
let start = offset + relative;
|
||||
let before = start
|
||||
.checked_sub(1)
|
||||
.and_then(|index| lower.as_bytes().get(index));
|
||||
let after = lower.as_bytes().get(start + name.len());
|
||||
if before.is_some_and(|byte| byte.is_ascii_whitespace())
|
||||
&& after.is_some_and(|byte| *byte == b'=' || byte.is_ascii_whitespace())
|
||||
{
|
||||
let rest = root[start + name.len()..].trim_start();
|
||||
let rest = rest.strip_prefix('=')?.trim_start();
|
||||
let quote = rest.chars().next()?;
|
||||
if quote == '\'' || quote == '"' {
|
||||
let value = &rest[quote.len_utf8()..];
|
||||
let end = value.find(quote)?;
|
||||
return Some(&value[..end]);
|
||||
}
|
||||
}
|
||||
offset = start + name.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn safe_svg_dimension(value: f64) -> Option<u32> {
|
||||
if !value.is_finite() || value <= 0.0 || value > f64::from(PROJECT_MEDIA_PREVIEW_MAX_DIMENSION)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(value.round() as u32)
|
||||
}
|
||||
|
||||
fn encode_project_resource_preview_data_url(
|
||||
media_type: &str,
|
||||
bytes: &[u8],
|
||||
@@ -539,6 +666,8 @@ mod tests {
|
||||
)
|
||||
.expect("safe svg");
|
||||
assert_eq!(preview.media_type, "image/svg+xml");
|
||||
assert_eq!(preview.pixel_width, None);
|
||||
assert_eq!(preview.pixel_height, None);
|
||||
assert!(preview.data_url.starts_with("data:image/svg+xml;base64,"));
|
||||
assert!(load_local_project_media_preview(
|
||||
root.path(),
|
||||
@@ -554,6 +683,54 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_preview_reports_safe_dimensions_for_extended_art_images() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("assets")).expect("assets dir");
|
||||
|
||||
let mut gif = b"GIF89a".to_vec();
|
||||
gif.extend_from_slice(&320_u16.to_le_bytes());
|
||||
gif.extend_from_slice(&180_u16.to_le_bytes());
|
||||
gif.extend_from_slice(&[0; 4]);
|
||||
fs::write(root.path().join("assets/scene.gif"), gif).expect("gif");
|
||||
|
||||
let mut bmp = vec![0_u8; 26];
|
||||
bmp[..2].copy_from_slice(b"BM");
|
||||
bmp[18..22].copy_from_slice(&640_i32.to_le_bytes());
|
||||
bmp[22..26].copy_from_slice(&(-360_i32).to_le_bytes());
|
||||
fs::write(root.path().join("assets/scene.bmp"), bmp).expect("bmp");
|
||||
|
||||
let mut avif = vec![0_u8; 32];
|
||||
avif[4..8].copy_from_slice(b"ftyp");
|
||||
avif[8..12].copy_from_slice(b"avif");
|
||||
avif[16..20].copy_from_slice(b"ispe");
|
||||
avif[24..28].copy_from_slice(&1920_u32.to_be_bytes());
|
||||
avif[28..32].copy_from_slice(&1080_u32.to_be_bytes());
|
||||
fs::write(root.path().join("assets/scene.avif"), avif).expect("avif");
|
||||
|
||||
fs::write(
|
||||
root.path().join("assets/scene.svg"),
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><path d="M0 0"/></svg>"#,
|
||||
)
|
||||
.expect("svg");
|
||||
|
||||
for (path, expected_type, expected_dimensions) in [
|
||||
("assets/scene.gif", "image/gif", Some((320, 180))),
|
||||
("assets/scene.bmp", "image/bmp", Some((640, 360))),
|
||||
("assets/scene.avif", "image/avif", Some((1920, 1080))),
|
||||
("assets/scene.svg", "image/svg+xml", Some((800, 600))),
|
||||
] {
|
||||
let preview =
|
||||
load_local_project_media_preview(root.path(), path, ProjectMediaPreviewKind::Art)
|
||||
.expect("extended image preview");
|
||||
assert_eq!(preview.media_type, expected_type);
|
||||
assert_eq!(
|
||||
preview.pixel_width.zip(preview.pixel_height),
|
||||
expected_dimensions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resource_preview_rejects_symlink_and_hardlink_files() {
|
||||
|
||||
@@ -105,6 +105,33 @@ fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_codex_art_commit_relays_standalone_manifest_invalidation() {
|
||||
let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard();
|
||||
let root = unique_project_path();
|
||||
let relay_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
|
||||
.expect("bind direct Codex manifest invalidation relay fixture");
|
||||
let relay_port = relay_listener
|
||||
.local_addr()
|
||||
.expect("read direct Codex manifest invalidation relay fixture address")
|
||||
.port();
|
||||
let relay_token = "c".repeat(64);
|
||||
sink_guard
|
||||
.configure(relay_port, &relay_token)
|
||||
.expect("configure direct Codex manifest invalidation relay fixture");
|
||||
|
||||
emit_game_creator_manifest_invalidated(&root, "direct-codex-art");
|
||||
|
||||
let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener)
|
||||
.expect("receive direct Codex manifest invalidation relay within deadline");
|
||||
let relay: GameCreatorManifestInvalidationRelayEnvelope =
|
||||
serde_json::from_slice(&relay_payload)
|
||||
.expect("parse direct Codex manifest invalidation relay");
|
||||
assert_eq!(relay.token, relay_token);
|
||||
assert_eq!(relay.event.project_path, root.to_string_lossy());
|
||||
assert_eq!(relay.event.agent_id, "direct-codex-art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() {
|
||||
let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
|
||||
|
||||
@@ -6142,6 +6142,11 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||||
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
|
||||
.expect("auto preview");
|
||||
assert_eq!(preview.media_type, "image/png");
|
||||
assert_eq!(preview.pixel_width, 1);
|
||||
assert_eq!(preview.pixel_height, 1);
|
||||
let serialized_preview = serde_json::to_value(&preview).expect("serialize image preview");
|
||||
assert_eq!(serialized_preview["pixelWidth"], 1);
|
||||
assert_eq!(serialized_preview["pixelHeight"], 1);
|
||||
|
||||
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
|
||||
let unregistered_error = read_local_project_image_preview_at(
|
||||
|
||||
@@ -6412,14 +6412,21 @@ export function App({
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setChatAgentBusy(false);
|
||||
const activeTurn = activeDirectCodexTurnRef.current;
|
||||
if (
|
||||
!activeTurn ||
|
||||
(activeTurn.projectPath === directProjectPath &&
|
||||
activeTurn.turnId === clientTurnId)
|
||||
) {
|
||||
resetDirectCodexTurn();
|
||||
try {
|
||||
if (localProjectPathRef.current === directProjectPath) {
|
||||
await refreshManifest(directProjectPath);
|
||||
}
|
||||
} finally {
|
||||
setChatAgentBusy(false);
|
||||
setDirectCodexProgress('');
|
||||
const activeTurn = activeDirectCodexTurnRef.current;
|
||||
if (
|
||||
!activeTurn ||
|
||||
(activeTurn.projectPath === directProjectPath &&
|
||||
activeTurn.turnId === clientTurnId)
|
||||
) {
|
||||
resetDirectCodexTurn();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -11565,8 +11572,8 @@ export function App({
|
||||
);
|
||||
const projectSupervisorHasConversationControls = Boolean(
|
||||
projectSupervisorRuntime?.pendingToolAction ||
|
||||
projectSupervisorRuntime?.userInputRequest ||
|
||||
projectSupervisorNeedsUserInput,
|
||||
projectSupervisorRuntime?.userInputRequest ||
|
||||
projectSupervisorNeedsUserInput,
|
||||
);
|
||||
const visibleAgentConversationMessages = latestVisibleItems(
|
||||
agentConversationMessages,
|
||||
|
||||
@@ -365,7 +365,11 @@ export interface AgentRuntimeResult {
|
||||
}
|
||||
|
||||
export type AgentRuntimeResponseStreamStatus =
|
||||
'streaming' | 'ready' | 'committed' | 'discarded' | 'failed';
|
||||
| 'streaming'
|
||||
| 'ready'
|
||||
| 'committed'
|
||||
| 'discarded'
|
||||
| 'failed';
|
||||
|
||||
export interface AgentRuntimeResponseStream {
|
||||
schemaVersion: string;
|
||||
@@ -486,13 +490,22 @@ export interface GameCreatorAgentLlmConfigStatus {
|
||||
}
|
||||
|
||||
export type GameCreatorLlmApiKind =
|
||||
'openai_responses' | 'openai_chat' | 'anthropic';
|
||||
| 'openai_responses'
|
||||
| 'openai_chat'
|
||||
| 'anthropic';
|
||||
export type GameCreatorAgentMode =
|
||||
'codex_app_server' | 'codex_cli' | 'provider';
|
||||
| 'codex_app_server'
|
||||
| 'codex_cli'
|
||||
| 'provider';
|
||||
export type RuntimeLlmProviderPresetId =
|
||||
'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark';
|
||||
| 'custom'
|
||||
| 'openai'
|
||||
| 'deepseek'
|
||||
| 'anthropic'
|
||||
| 'ark';
|
||||
export type RuntimeAgentLlmProviderPresetId =
|
||||
'inherit' | RuntimeLlmProviderPresetId;
|
||||
| 'inherit'
|
||||
| RuntimeLlmProviderPresetId;
|
||||
|
||||
export interface GameCreatorLlmConfig {
|
||||
apiKey: string;
|
||||
@@ -795,7 +808,12 @@ export interface AgentProgressEvent {
|
||||
}
|
||||
|
||||
export type GameCreatorDirectTurnUpdateStatus =
|
||||
'accepted' | 'running' | 'streaming' | 'finalizing' | 'completed' | 'failed';
|
||||
| 'accepted'
|
||||
| 'running'
|
||||
| 'streaming'
|
||||
| 'finalizing'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type GameCreatorDirectTurnActivity =
|
||||
| 'request-accepted'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
const COMMIT_ID_SUFFIX =
|
||||
/--[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const WINDOWS_RESERVED_NAMES = new Set([
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'NUL',
|
||||
'COM1',
|
||||
'COM2',
|
||||
'COM3',
|
||||
'COM4',
|
||||
'COM5',
|
||||
'COM6',
|
||||
'COM7',
|
||||
'COM8',
|
||||
'COM9',
|
||||
'LPT1',
|
||||
'LPT2',
|
||||
'LPT3',
|
||||
'LPT4',
|
||||
'LPT5',
|
||||
'LPT6',
|
||||
'LPT7',
|
||||
'LPT8',
|
||||
'LPT9',
|
||||
]);
|
||||
const FORBIDDEN_FILE_NAME_CHARACTERS = '/\\:*?"<>|';
|
||||
|
||||
function isForbiddenFileNameCharacter(value: string) {
|
||||
const code = value.codePointAt(0) ?? 0;
|
||||
return (
|
||||
code <= 0x1f ||
|
||||
code === 0x7f ||
|
||||
FORBIDDEN_FILE_NAME_CHARACTERS.includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeAssetBaseName(value: string, fallback: string) {
|
||||
const normalized = value
|
||||
.normalize('NFC')
|
||||
.split('')
|
||||
.filter((character) => !isForbiddenFileNameCharacter(character))
|
||||
.join('');
|
||||
const bounded = Array.from(normalized)
|
||||
.slice(0, 80)
|
||||
.join('')
|
||||
.replace(/[. ]+$/u, '');
|
||||
if (
|
||||
bounded.length === 0 ||
|
||||
WINDOWS_RESERVED_NAMES.has(bounded.split('.')[0]?.toUpperCase() ?? '')
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
return bounded;
|
||||
}
|
||||
|
||||
export function canonicalAssetBaseName(
|
||||
localPath: string,
|
||||
fallback = '画布素材',
|
||||
) {
|
||||
const fileName = localPath.split(/[\\/]/u).filter(Boolean).pop() ?? '';
|
||||
let stem = fileName.replace(/\.[^.]+$/u, '');
|
||||
let next = stem.replace(COMMIT_ID_SUFFIX, '');
|
||||
while (next !== stem) {
|
||||
stem = next;
|
||||
next = stem.replace(COMMIT_ID_SUFFIX, '');
|
||||
}
|
||||
return sanitizeAssetBaseName(next, fallback);
|
||||
}
|
||||
|
||||
export function isValidAssetCanvasName(name: string) {
|
||||
if (
|
||||
name.length === 0 ||
|
||||
Array.from(name).length > 80 ||
|
||||
Array.from(name).some(isForbiddenFileNameCharacter) ||
|
||||
/[. ]$/u.test(name) ||
|
||||
WINDOWS_RESERVED_NAMES.has(name.split('.')[0]?.toUpperCase() ?? '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+218
-18
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
ImageCanvasDraft,
|
||||
ImageCanvasGenerationCommitResult,
|
||||
ImageCanvasGenerationPort,
|
||||
ImageCanvasGenerationProgress,
|
||||
ImageCanvasGenerationRecord,
|
||||
@@ -8,6 +7,7 @@ import type {
|
||||
ImageCanvasHostPort,
|
||||
ImageCanvasHostResult,
|
||||
ImageCanvasHostScope,
|
||||
ImageCanvasLocalImportResult,
|
||||
ImageCanvasMediaRef,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
@@ -52,6 +52,12 @@ type StoredMediaResult = {
|
||||
draft: ImageCanvasDraft | null;
|
||||
};
|
||||
|
||||
type ImportedLocalImagesResult = {
|
||||
status: 'imported' | 'cancelled' | 'conflict';
|
||||
draft: ImageCanvasDraft | null;
|
||||
importedLayerIds: string[];
|
||||
};
|
||||
|
||||
type StagedImageResult = {
|
||||
status: 'staged' | 'conflict';
|
||||
stagedImageToken: string | null;
|
||||
@@ -62,8 +68,20 @@ type StagedImageResult = {
|
||||
|
||||
type GenerationCommandResult = {
|
||||
generation: ImageCanvasGenerationRecord;
|
||||
images: [];
|
||||
commit: ImageCanvasGenerationCommitResult;
|
||||
draft: ImageCanvasDraft;
|
||||
images: Array<{
|
||||
mediaRef: ImageCanvasMediaRef;
|
||||
resourceId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type GenerationFailureCommandResult = {
|
||||
disposition:
|
||||
| 'failed'
|
||||
| 'reconciliation-required'
|
||||
| 'not-started'
|
||||
| 'already-terminal';
|
||||
draft: ImageCanvasDraft;
|
||||
};
|
||||
|
||||
type GenerationProgressEvent = ImageCanvasGenerationProgress & {
|
||||
@@ -173,20 +191,22 @@ function failure(error: unknown): ImageCanvasHostResult<never> {
|
||||
const normalized = message.toLowerCase();
|
||||
return {
|
||||
status: 'failed',
|
||||
code: message.includes('reconciliation-required')
|
||||
? 'reconciliation-required'
|
||||
: message.includes('project-identity-conflict')
|
||||
? 'project-identity-conflict'
|
||||
: normalized.includes('external editor api')
|
||||
? 'platform-service-configuration'
|
||||
: normalized.includes('authentication-required') ||
|
||||
message.includes('登录已失效')
|
||||
? 'authentication-required'
|
||||
: message.includes('泥点余额不足')
|
||||
? 'insufficient-mud-points'
|
||||
: message.includes('平台图片生成服务暂不可用')
|
||||
? 'platform-service-configuration'
|
||||
: 'tauri-command-failed',
|
||||
code: message.includes('素材名称无效')
|
||||
? 'asset-name-invalid'
|
||||
: message.includes('reconciliation-required')
|
||||
? 'reconciliation-required'
|
||||
: message.includes('project-identity-conflict')
|
||||
? 'project-identity-conflict'
|
||||
: normalized.includes('external editor api')
|
||||
? 'platform-service-configuration'
|
||||
: normalized.includes('authentication-required') ||
|
||||
message.includes('登录已失效')
|
||||
? 'authentication-required'
|
||||
: message.includes('泥点余额不足')
|
||||
? 'insufficient-mud-points'
|
||||
: message.includes('平台图片生成服务暂不可用')
|
||||
? 'platform-service-configuration'
|
||||
: 'tauri-command-failed',
|
||||
message,
|
||||
};
|
||||
}
|
||||
@@ -228,6 +248,8 @@ export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort
|
||||
message: '当前阶段使用明确 mock,不会提交真实 AI 生成请求',
|
||||
});
|
||||
return {
|
||||
settleGenerationFailure: unsupported,
|
||||
archiveFailedGeneration: unsupported,
|
||||
generateImage: unsupported,
|
||||
recoverImages: async () => ({
|
||||
status: 'ok' as const,
|
||||
@@ -306,6 +328,53 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
);
|
||||
};
|
||||
const generation: ImageCanvasGenerationPort = {
|
||||
async settleGenerationFailure(failureInput) {
|
||||
try {
|
||||
const result = await invokeInput<GenerationFailureCommandResult>(
|
||||
'finalize_local_project_asset_canvas_generation_failure',
|
||||
{
|
||||
...baseScope(failureInput.scope),
|
||||
expectedDraftRevision: safeRevision(
|
||||
failureInput.expectedDraftRevision,
|
||||
'expectedDraftRevision',
|
||||
),
|
||||
generationId: failureInput.generationId,
|
||||
intentId: failureInput.intentId,
|
||||
errorCode: failureInput.errorCode,
|
||||
reconciliationRequired: failureInput.reconciliationRequired,
|
||||
},
|
||||
);
|
||||
return { status: 'ok', value: result };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes('draft-revision-conflict')
|
||||
? conflict('draft-revision', null, null)
|
||||
: failure(error);
|
||||
}
|
||||
},
|
||||
async archiveFailedGeneration(archiveInput) {
|
||||
try {
|
||||
const result = await invokeInput<{
|
||||
generationId: string;
|
||||
phase: 'archived';
|
||||
archivedAt: number;
|
||||
draft: ImageCanvasDraft;
|
||||
}>('archive_failed_local_project_asset_canvas_generation', {
|
||||
...baseScope(archiveInput.scope),
|
||||
expectedDraftRevision: safeRevision(
|
||||
archiveInput.expectedDraftRevision,
|
||||
'expectedDraftRevision',
|
||||
),
|
||||
generationId: archiveInput.generationId,
|
||||
});
|
||||
return { status: 'ok', value: result.draft };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes('draft-revision-conflict')
|
||||
? conflict('draft-revision', null, null)
|
||||
: failure(error);
|
||||
}
|
||||
},
|
||||
async generateImage(generationInput) {
|
||||
let unlisten: (() => void) | undefined;
|
||||
try {
|
||||
@@ -337,9 +406,30 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
assetKind: generationInput.assetKind,
|
||||
assetName: generationInput.assetName,
|
||||
referenceResourceIds: generationInput.referenceResourceIds,
|
||||
sourceLayerId: generationInput.sourceLayerId,
|
||||
placeholder: generationInput.placeholder,
|
||||
},
|
||||
);
|
||||
return { status: 'ok', value: result };
|
||||
const images = await Promise.all(
|
||||
result.images.map(async (image) => {
|
||||
const preview = await invokeInput<{
|
||||
mediaType: string;
|
||||
bytes: number[];
|
||||
}>('read_local_project_asset_canvas_media', {
|
||||
...baseScope(generationInput.scope),
|
||||
mediaRef: image.mediaRef,
|
||||
});
|
||||
return {
|
||||
mediaRef: image.mediaRef,
|
||||
resourceId: image.resourceId,
|
||||
previewUrl: bytesToPreviewUrl(preview.bytes, preview.mediaType),
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
status: 'ok',
|
||||
value: { generation: result.generation, images, draft: result.draft },
|
||||
};
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
} finally {
|
||||
@@ -477,6 +567,44 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
},
|
||||
},
|
||||
asset: {
|
||||
async importLocalImages(importInput) {
|
||||
try {
|
||||
const result = await invokeInput<ImportedLocalImagesResult>(
|
||||
'import_local_project_asset_canvas_images',
|
||||
{
|
||||
...baseScope(importInput.scope),
|
||||
expectedDraftRevision: safeRevision(
|
||||
importInput.expectedDraftRevision,
|
||||
'expectedDraftRevision',
|
||||
),
|
||||
viewportWidth: importInput.viewportSize.width,
|
||||
viewportHeight: importInput.viewportSize.height,
|
||||
},
|
||||
);
|
||||
if (result.status === 'conflict') {
|
||||
return conflict('draft-revision', result.draft, null);
|
||||
}
|
||||
if (result.status === 'cancelled') {
|
||||
return {
|
||||
status: 'ok',
|
||||
value: { status: 'cancelled' },
|
||||
} satisfies ImageCanvasHostResult<ImageCanvasLocalImportResult>;
|
||||
}
|
||||
if (!result.draft) {
|
||||
throw new Error('Tauri 导入完成后未返回权威草稿');
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
value: {
|
||||
status: 'imported',
|
||||
draft: result.draft,
|
||||
importedLayerIds: result.importedLayerIds,
|
||||
},
|
||||
} satisfies ImageCanvasHostResult<ImageCanvasLocalImportResult>;
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
},
|
||||
async importImages(importInput) {
|
||||
const images = [];
|
||||
try {
|
||||
@@ -538,6 +666,78 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
}
|
||||
},
|
||||
completion: {
|
||||
async commitSelectedCandidate(commitInput) {
|
||||
try {
|
||||
const expectedDraftRevision = safeRevision(
|
||||
commitInput.expectedDraftRevision,
|
||||
'expectedDraftRevision',
|
||||
);
|
||||
const expectedRevision = safeRevisionString(
|
||||
commitInput.expectedHostRevision,
|
||||
'expectedHostRevision',
|
||||
);
|
||||
const result = await invokeInput<LocalAssetCommitResult>(
|
||||
'commit_local_project_asset_canvas_candidate',
|
||||
{
|
||||
...baseScope(commitInput.scope),
|
||||
expectedRevision,
|
||||
expectedDraftRevision,
|
||||
commitId: commitInput.commitId,
|
||||
idempotencyKey: commitInput.idempotencyKey,
|
||||
sourceAssetId: commitInput.scope.sourceAssetId,
|
||||
sourceLayerId: commitInput.sourceLayerId,
|
||||
name: commitInput.name,
|
||||
assetKind: commitInput.assetKind,
|
||||
referenceResourceIds: commitInput.referenceResourceIds,
|
||||
},
|
||||
);
|
||||
if (result.status === 'conflict') {
|
||||
return conflict(
|
||||
result.conflictKind === 'project-revision'
|
||||
? 'host-revision'
|
||||
: result.conflictKind,
|
||||
null,
|
||||
result.projectRevision,
|
||||
);
|
||||
}
|
||||
if (
|
||||
result.status === 'rolled-back' ||
|
||||
result.status === 'reconciliation-required'
|
||||
) {
|
||||
return {
|
||||
status: 'failed',
|
||||
code: result.status,
|
||||
message:
|
||||
result.status === 'rolled-back'
|
||||
? '最终图片替换已安全回滚'
|
||||
: '最终图片替换需要恢复或人工对账',
|
||||
};
|
||||
}
|
||||
if (
|
||||
result.status !== 'committed' &&
|
||||
result.status !== 'already-committed'
|
||||
) {
|
||||
return failure('最终图片替换返回未知状态');
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
value: {
|
||||
resourceId: result.asset.source.resourceId ?? result.asset.id,
|
||||
assetId: result.asset.id,
|
||||
projectId: result.projectId,
|
||||
commitId: result.commitId,
|
||||
committedProjectRevision: result.committedProjectRevision,
|
||||
draftRevision: result.draftRevision,
|
||||
hostRevision: String(result.projectRevision),
|
||||
commitStatus: result.status,
|
||||
manifest: result.manifest,
|
||||
eventId: result.eventId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
},
|
||||
async commitImage(commitInput) {
|
||||
try {
|
||||
const expectedDraftRevision = safeRevision(
|
||||
|
||||
+18
-18
@@ -201,10 +201,10 @@ function gameChatRuntimeActivityTimes(runtime: AgentRuntimeState) {
|
||||
function gameChatRuntimeIsExpectedWait(runtime: AgentRuntimeState) {
|
||||
return Boolean(
|
||||
runtime.pendingToolAction ||
|
||||
runtime.userInputRequest ||
|
||||
[runtime.status, runtime.phase, runtime.goalStatus].some((state) =>
|
||||
GAME_CHAT_EXPECTED_WAIT_STATES.has(state ?? ''),
|
||||
),
|
||||
runtime.userInputRequest ||
|
||||
[runtime.status, runtime.phase, runtime.goalStatus].some((state) =>
|
||||
GAME_CHAT_EXPECTED_WAIT_STATES.has(state ?? ''),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -984,11 +984,11 @@ export function SupervisorChatOnlyView({
|
||||
!directCodex && Boolean(expectedRunId && runtime?.runId !== expectedRunId);
|
||||
const running = Boolean(
|
||||
chatAgentBusy ||
|
||||
(!directCodex && synchronizingAcceptedRun) ||
|
||||
(runtime &&
|
||||
runtime.status !== 'needs-reconciliation' &&
|
||||
runtime.phase !== 'needs-reconciliation' &&
|
||||
!isAgentRuntimeTerminalState(runtime)),
|
||||
(!directCodex && synchronizingAcceptedRun) ||
|
||||
(runtime &&
|
||||
runtime.status !== 'needs-reconciliation' &&
|
||||
runtime.phase !== 'needs-reconciliation' &&
|
||||
!isAgentRuntimeTerminalState(runtime)),
|
||||
);
|
||||
const gameChatInterruptionText =
|
||||
!directCodex && gameChatMode && runtime
|
||||
@@ -1113,18 +1113,18 @@ export function SupervisorChatOnlyView({
|
||||
: null;
|
||||
const expectedRuntimeWait = Boolean(
|
||||
needsUserInput ||
|
||||
pendingConfirmation ||
|
||||
pendingCommand ||
|
||||
(activeRuntimeLanes.length > 0 &&
|
||||
activeRuntimeLanes.every(gameChatRuntimeIsExpectedWait)),
|
||||
pendingConfirmation ||
|
||||
pendingCommand ||
|
||||
(activeRuntimeLanes.length > 0 &&
|
||||
activeRuntimeLanes.every(gameChatRuntimeIsExpectedWait)),
|
||||
);
|
||||
const runtimeAppearsStalled = Boolean(
|
||||
running &&
|
||||
runtime &&
|
||||
!runtimeTerminal &&
|
||||
!expectedRuntimeWait &&
|
||||
inactiveRuntimeMs !== null &&
|
||||
inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS,
|
||||
runtime &&
|
||||
!runtimeTerminal &&
|
||||
!expectedRuntimeWait &&
|
||||
inactiveRuntimeMs !== null &&
|
||||
inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS,
|
||||
);
|
||||
const runStateLabel = (() => {
|
||||
if (gameChatInterruptionText) {
|
||||
|
||||
@@ -5028,6 +5028,10 @@ iframe.preview-frame {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-workbench-layout.is-ui-editor {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-workbench-stage,
|
||||
.game-workbench-chat {
|
||||
min-width: 0;
|
||||
@@ -5055,10 +5059,17 @@ iframe.preview-frame {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'] {
|
||||
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'],
|
||||
.game-workbench-stage[data-resource-view-state='resources.ui-editor'] {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.game-workbench-stage[data-resource-view-state='resources.ui-editor']
|
||||
> div:last-child {
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas']
|
||||
> .asset-canvas-surface {
|
||||
grid-row: 2;
|
||||
@@ -5276,6 +5287,18 @@ iframe.preview-frame {
|
||||
background-color: #fffdfa;
|
||||
background-image: radial-gradient(#eaded8 0.8px, transparent 0.8px);
|
||||
background-size: 18px 18px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.game-resource-canvas--dependency {
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.game-resource-canvas--dependency:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.game-resource-canvas-content {
|
||||
@@ -5287,6 +5310,12 @@ iframe.preview-frame {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.game-resource-canvas--dependency .game-resource-canvas-content {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-resource-section-stack {
|
||||
display: contents;
|
||||
}
|
||||
@@ -5313,6 +5342,35 @@ iframe.preview-frame {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.game-resource-world-section-heading {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
left: 24px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 32px;
|
||||
padding: 0 8px;
|
||||
border-bottom: 1px solid #eaded8;
|
||||
color: #725348;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-resource-world-section-heading > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.game-resource-world-section-heading small {
|
||||
color: #9b6b56;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-resource-dependency-edge,
|
||||
.game-resource-dependency-edge path {
|
||||
fill: none;
|
||||
@@ -5445,6 +5503,29 @@ iframe.preview-frame {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.game-resource-canvas--dependency .game-resource-section {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.game-resource-canvas--dependency .game-resource-section-viewport {
|
||||
overflow: visible;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.game-resource-canvas--dependency .game-resource-section-height-actions,
|
||||
.game-resource-canvas--dependency .game-resource-section-zoom-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-resource-canvas--dependency .game-resource-section {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-resource-canvas--dependency .game-resource-card,
|
||||
.game-resource-canvas--dependency .game-resource-section > header {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-resource-section-viewport:focus-visible {
|
||||
outline: 2px solid rgb(206 118 80 / 26%);
|
||||
outline-offset: -2px;
|
||||
@@ -5658,15 +5739,44 @@ iframe.preview-frame {
|
||||
}
|
||||
|
||||
.game-resource-focus {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: min(560px, calc(100% - 32px));
|
||||
max-height: calc(100% - 32px);
|
||||
border: 1px solid #ead8cf;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 52px rgb(73 42 29 / 20%);
|
||||
overflow: hidden;
|
||||
background: #fffdfa;
|
||||
color: #563b31;
|
||||
outline: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.game-resource-image-preview {
|
||||
display: grid;
|
||||
min-height: 160px;
|
||||
max-height: 280px;
|
||||
padding: 12px;
|
||||
border: 1px solid #ead8cf;
|
||||
border-radius: 8px;
|
||||
background: #f8f0eb;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-resource-image-preview img {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
max-height: 252px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.game-resource-focus-titlebar {
|
||||
|
||||
+41
-13
@@ -13,8 +13,8 @@ import type {
|
||||
ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
resourceCanvasCardSize,
|
||||
type ResourceCanvasCardSizeByResourceId,
|
||||
} from './resourceCanvasLayoutModel';
|
||||
import {
|
||||
type ProjectResourceGraph,
|
||||
@@ -38,8 +38,9 @@ type RectLookup = {
|
||||
export type ResourceDependencyOverlayProps = {
|
||||
graph: ProjectResourceGraph;
|
||||
positions: readonly ProjectResourceCanvasPosition[];
|
||||
section: ProjectResourceCanvasSection;
|
||||
section?: ProjectResourceCanvasSection;
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId;
|
||||
geometryRevision?: string;
|
||||
};
|
||||
|
||||
@@ -458,7 +459,14 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
ResourceDependencyOverlayHandle,
|
||||
ResourceDependencyOverlayProps
|
||||
>(function ResourceDependencyOverlay(
|
||||
{ geometryRevision = '', graph, positions, section, visibleResourceIds },
|
||||
{
|
||||
cardSizeByResourceId,
|
||||
geometryRevision = '',
|
||||
graph,
|
||||
positions,
|
||||
section,
|
||||
visibleResourceIds,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
|
||||
@@ -484,6 +492,10 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
useLayoutEffect(() => {
|
||||
const plane = overlayRef.current?.parentElement;
|
||||
const viewport = plane?.closest<HTMLElement>(SECTION_SCROLL_SELECTOR);
|
||||
if (plane?.closest('.game-resource-canvas--dependency')) {
|
||||
setLogicalViewport(null);
|
||||
return undefined;
|
||||
}
|
||||
if (!plane || !viewport) {
|
||||
setLogicalViewport(null);
|
||||
return undefined;
|
||||
@@ -533,18 +545,22 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
viewport.removeEventListener('scroll', scheduleMeasure);
|
||||
window.removeEventListener('resize', scheduleMeasure);
|
||||
};
|
||||
}, [geometryRevision, section]);
|
||||
}, [geometryRevision]);
|
||||
|
||||
const rectByResourceId = useMemo(() => {
|
||||
const result = new Map<string, Rect>();
|
||||
for (const position of positions) {
|
||||
if (
|
||||
position.section !== section ||
|
||||
(section !== undefined && position.section !== section) ||
|
||||
!graph.resourceIds.has(position.resourceId) ||
|
||||
!visibleResourceIds.has(position.resourceId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const cardSize = resourceCanvasCardSize(
|
||||
position.resourceId,
|
||||
cardSizeByResourceId,
|
||||
);
|
||||
result.set(position.resourceId, {
|
||||
x:
|
||||
dragPreview?.resourceId === position.resourceId
|
||||
@@ -554,12 +570,19 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
dragPreview?.resourceId === position.resourceId
|
||||
? dragPreview.y
|
||||
: position.y,
|
||||
width: RESOURCE_CANVAS_CARD_WIDTH,
|
||||
height: RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
width: cardSize.width,
|
||||
height: cardSize.height,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [dragPreview, graph.resourceIds, positions, section, visibleResourceIds]);
|
||||
}, [
|
||||
cardSizeByResourceId,
|
||||
dragPreview,
|
||||
graph.resourceIds,
|
||||
positions,
|
||||
section,
|
||||
visibleResourceIds,
|
||||
]);
|
||||
const sectionByResourceId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
@@ -571,8 +594,9 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
() =>
|
||||
graph.referenceEdges.filter(
|
||||
(edge) =>
|
||||
sectionByResourceId.get(edge.sourceResourceId) === section &&
|
||||
referenceStaysInSection(edge, sectionByResourceId),
|
||||
section === undefined ||
|
||||
(sectionByResourceId.get(edge.sourceResourceId) === section &&
|
||||
referenceStaysInSection(edge, sectionByResourceId)),
|
||||
),
|
||||
[graph.referenceEdges, section, sectionByResourceId],
|
||||
);
|
||||
@@ -651,8 +675,12 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
<svg
|
||||
ref={overlayRef}
|
||||
className="game-resource-dependency-overlay"
|
||||
data-testid={`resource-dependency-overlay-${section}`}
|
||||
data-resource-section={section}
|
||||
data-testid={
|
||||
section
|
||||
? `resource-dependency-overlay-${section}`
|
||||
: 'resource-dependency-overlay-world'
|
||||
}
|
||||
{...(section ? { 'data-resource-section': section } : {})}
|
||||
data-logical-viewport={
|
||||
logicalViewport
|
||||
? `${logicalViewport.x},${logicalViewport.y},${logicalViewport.width},${logicalViewport.height}`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+422
-64
File diff suppressed because it is too large
Load Diff
@@ -18,10 +18,17 @@ export type ProjectResourceCardPreviewKind =
|
||||
| 'version'
|
||||
| 'placeholder';
|
||||
|
||||
export type ProjectResourceImageDimensions = {
|
||||
pixelWidth: number;
|
||||
pixelHeight: number;
|
||||
};
|
||||
|
||||
export type ProjectResourceCardPreviewPayload = {
|
||||
path: string;
|
||||
mediaType: string;
|
||||
byteLen: number;
|
||||
pixelWidth?: number;
|
||||
pixelHeight?: number;
|
||||
sourceUrl?: string;
|
||||
content?: string;
|
||||
};
|
||||
@@ -33,6 +40,29 @@ export type ProjectResourceCardPreviewTransportPayload = Omit<
|
||||
dataUrl?: string;
|
||||
};
|
||||
|
||||
export function projectResourceCardPreviewImageDimensions(
|
||||
preview: Pick<
|
||||
ProjectResourceCardPreviewPayload,
|
||||
'pixelWidth' | 'pixelHeight'
|
||||
>,
|
||||
): ProjectResourceImageDimensions | null {
|
||||
const { pixelWidth, pixelHeight } = preview;
|
||||
if (pixelWidth === undefined && pixelHeight === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof pixelWidth !== 'number' ||
|
||||
typeof pixelHeight !== 'number' ||
|
||||
!Number.isSafeInteger(pixelWidth) ||
|
||||
!Number.isSafeInteger(pixelHeight) ||
|
||||
pixelWidth <= 0 ||
|
||||
pixelHeight <= 0
|
||||
) {
|
||||
throw new Error('图片预览像素尺寸无效');
|
||||
}
|
||||
return { pixelWidth, pixelHeight };
|
||||
}
|
||||
|
||||
export type ProjectResourceCardPreviewCacheEntry = {
|
||||
identity: string;
|
||||
retainedBytes: number;
|
||||
|
||||
+2
@@ -74,6 +74,8 @@ export function createResourceSignature(resources: ResourceCanvasItem[]) {
|
||||
resource.label,
|
||||
resource.dependencyDepth,
|
||||
resource.mediaType,
|
||||
resource.cardSize?.width ?? null,
|
||||
resource.cardSize?.height ?? null,
|
||||
]),
|
||||
)
|
||||
.sort()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user