补齐项目图片资源预览
在工作台资源浮层中直接按比例展示已登记图片 新增受控 Tauri 图片读取以及权限、路径、格式和尺寸校验 补齐加载失败状态、布局回归、Rust 测试和技术文档
This commit is contained in:
@@ -26,10 +26,12 @@ const viteConfigSource = fs.readFileSync(
|
||||
new URL('../vite.config.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const appInvokeSource = fs.readFileSync(
|
||||
const appInvokeSource = [
|
||||
new URL('../src/App.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
new URL('../src/view/project-development/index.tsx', import.meta.url),
|
||||
]
|
||||
.map((path) => fs.readFileSync(path, 'utf8'))
|
||||
.join('\n');
|
||||
const appEntrypointSource = fs.readFileSync(
|
||||
new URL('../src/main.tsx', import.meta.url),
|
||||
'utf8',
|
||||
|
||||
@@ -1057,6 +1057,29 @@ pub(crate) fn read_local_project_file(
|
||||
read_local_project_file_at(root, &normalized_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_image_preview(
|
||||
project_path: String,
|
||||
relative_path: String,
|
||||
) -> Result<LocalProjectImagePreview, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||||
let is_registered_asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.any(|asset| asset.local_path == normalized_path);
|
||||
let is_completed_task_artifact = manifest.tasks.iter().any(|task| {
|
||||
task.status == GameCreationAppTaskStatus::Completed
|
||||
&& task.artifacts.iter().any(|path| path == &normalized_path)
|
||||
});
|
||||
if !is_registered_asset && !is_completed_task_artifact {
|
||||
return Err("只能预览已登记资源或已完成任务的图片产物".to_string());
|
||||
}
|
||||
load_local_project_image_preview(root, &normalized_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn write_local_project_file(
|
||||
project_path: String,
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::project::{
|
||||
reject_sensitive_project_file_read, resolve_local_project_path,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
@@ -12,6 +13,17 @@ use std::path::Path;
|
||||
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_IMAGES: usize = 2;
|
||||
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
|
||||
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_TOTAL_BYTES: u64 = 12 * 1024 * 1024;
|
||||
const PROJECT_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 8_192;
|
||||
const PROJECT_IMAGE_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectImagePreview {
|
||||
pub(crate) path: String,
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) data_url: String,
|
||||
}
|
||||
|
||||
pub(crate) struct AgentRuntimeInspectionImage {
|
||||
pub(crate) relative_path: String,
|
||||
@@ -31,6 +43,26 @@ impl AgentRuntimeInspectionImage {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_local_project_image_preview(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<LocalProjectImagePreview, String> {
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
if !normalized.starts_with("assets/") && !normalized.starts_with("game/") {
|
||||
return Err("图片预览只允许读取 assets/ 或 game/ 下的项目图片".to_string());
|
||||
}
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
let absolute = resolve_local_project_path(root, &normalized)?;
|
||||
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
|
||||
let image = read_agent_runtime_inspection_image(&absolute, normalized)?;
|
||||
Ok(LocalProjectImagePreview {
|
||||
path: image.relative_path.clone(),
|
||||
media_type: image.media_type.to_string(),
|
||||
byte_len: image.byte_len,
|
||||
data_url: image.data_url(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_agent_runtime_inspection_images(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -190,7 +222,22 @@ fn read_agent_runtime_inspection_image(
|
||||
));
|
||||
}
|
||||
let media_type = detect_agent_runtime_image_media_type(&bytes)
|
||||
.ok_or_else(|| format!("image.inspect 只支持 PNG、JPEG、WEBP 或 GIF:{relative_path}"))?;
|
||||
.ok_or_else(|| format!("image.inspect 只支持 PNG、JPEG 或 WEBP:{relative_path}"))?;
|
||||
let (width, height) = detect_raster_image_dimensions(&bytes, media_type)
|
||||
.ok_or_else(|| format!("image.inspect 图片结构无效:{relative_path}"))?;
|
||||
let pixels = u64::from(width)
|
||||
.checked_mul(u64::from(height))
|
||||
.ok_or_else(|| format!("image.inspect 图片尺寸溢出:{relative_path}"))?;
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > PROJECT_IMAGE_PREVIEW_MAX_DIMENSION
|
||||
|| height > PROJECT_IMAGE_PREVIEW_MAX_DIMENSION
|
||||
|| pixels > PROJECT_IMAGE_PREVIEW_MAX_PIXELS
|
||||
{
|
||||
return Err(format!(
|
||||
"image.inspect 图片尺寸过大:{width}x{height},最大边长 {PROJECT_IMAGE_PREVIEW_MAX_DIMENSION},最大像素 {PROJECT_IMAGE_PREVIEW_MAX_PIXELS}:{relative_path}"
|
||||
));
|
||||
}
|
||||
let sha256 = format!("{:x}", Sha256::digest(&bytes));
|
||||
Ok(AgentRuntimeInspectionImage {
|
||||
relative_path,
|
||||
@@ -208,13 +255,112 @@ fn detect_agent_runtime_image_media_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
Some("image/jpeg")
|
||||
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
|
||||
Some("image/webp")
|
||||
} else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
||||
Some("image/gif")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32, u32)> {
|
||||
match media_type {
|
||||
"image/png" if bytes.len() >= 24 && &bytes[12..16] == b"IHDR" => Some((
|
||||
u32::from_be_bytes(bytes[16..20].try_into().ok()?),
|
||||
u32::from_be_bytes(bytes[20..24].try_into().ok()?),
|
||||
)),
|
||||
"image/jpeg" => detect_jpeg_dimensions(bytes),
|
||||
"image/webp" => detect_webp_dimensions(bytes),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if !bytes.starts_with(&[0xff, 0xd8]) {
|
||||
return None;
|
||||
}
|
||||
let mut index = 2usize;
|
||||
while index + 3 < bytes.len() {
|
||||
if bytes[index] != 0xff {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
while index < bytes.len() && bytes[index] == 0xff {
|
||||
index += 1;
|
||||
}
|
||||
let marker = *bytes.get(index)?;
|
||||
index += 1;
|
||||
if marker == 0xd9 || marker == 0xda {
|
||||
break;
|
||||
}
|
||||
if marker == 0x01 || (0xd0..=0xd8).contains(&marker) {
|
||||
continue;
|
||||
}
|
||||
let segment_len = usize::from(u16::from_be_bytes([
|
||||
*bytes.get(index)?,
|
||||
*bytes.get(index + 1)?,
|
||||
]));
|
||||
if segment_len < 2 || index.checked_add(segment_len)? > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
marker,
|
||||
0xc0 | 0xc1
|
||||
| 0xc2
|
||||
| 0xc3
|
||||
| 0xc5
|
||||
| 0xc6
|
||||
| 0xc7
|
||||
| 0xc9
|
||||
| 0xca
|
||||
| 0xcb
|
||||
| 0xcd
|
||||
| 0xce
|
||||
| 0xcf
|
||||
) && segment_len >= 7
|
||||
{
|
||||
let height = u32::from(u16::from_be_bytes([
|
||||
*bytes.get(index + 3)?,
|
||||
*bytes.get(index + 4)?,
|
||||
]));
|
||||
let width = u32::from(u16::from_be_bytes([
|
||||
*bytes.get(index + 5)?,
|
||||
*bytes.get(index + 6)?,
|
||||
]));
|
||||
return Some((width, height));
|
||||
}
|
||||
index += segment_len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if bytes.len() < 30 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" {
|
||||
return None;
|
||||
}
|
||||
match &bytes[12..16] {
|
||||
b"VP8X" if bytes.len() >= 30 => {
|
||||
let width = 1
|
||||
+ u32::from(bytes[24])
|
||||
+ (u32::from(bytes[25]) << 8)
|
||||
+ (u32::from(bytes[26]) << 16);
|
||||
let height = 1
|
||||
+ u32::from(bytes[27])
|
||||
+ (u32::from(bytes[28]) << 8)
|
||||
+ (u32::from(bytes[29]) << 16);
|
||||
Some((width, height))
|
||||
}
|
||||
b"VP8 " if bytes.len() >= 30 && bytes[23..26] == [0x9d, 0x01, 0x2a] => Some((
|
||||
u32::from(u16::from_le_bytes([bytes[26], bytes[27]]) & 0x3fff),
|
||||
u32::from(u16::from_le_bytes([bytes[28], bytes[29]]) & 0x3fff),
|
||||
)),
|
||||
b"VP8L" if bytes.len() >= 25 && bytes[20] == 0x2f => Some((
|
||||
1 + u32::from(bytes[21]) + ((u32::from(bytes[22]) & 0x3f) << 8),
|
||||
1 + (u32::from(bytes[22]) >> 6)
|
||||
+ (u32::from(bytes[23]) << 2)
|
||||
+ ((u32::from(bytes[24]) & 0x0f) << 10),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_path_component(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
@@ -351,7 +497,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_bytes() -> Vec<u8> {
|
||||
b"\x89PNG\r\n\x1a\nvisual-test".to_vec()
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||||
.expect("valid 1x1 png")
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -370,6 +518,30 @@ mod tests {
|
||||
assert!(images[0].data_url().starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_image_preview_returns_renderable_data_url() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
|
||||
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
|
||||
|
||||
let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png")
|
||||
.expect("load project preview");
|
||||
|
||||
assert_eq!(preview.path, "assets/ui/prototype.png");
|
||||
assert_eq!(preview.media_type, "image/png");
|
||||
assert_eq!(preview.byte_len, png_bytes().len() as u64);
|
||||
assert!(preview.data_url.starts_with("data:image/png;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_image_preview_rejects_non_project_asset_paths() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let error = load_local_project_image_preview(root.path(), "memory/project.png")
|
||||
.err()
|
||||
.expect("memory image rejected");
|
||||
assert!(error.contains("assets/ 或 game/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_inspect_rejects_runtime_evidence_from_another_run() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
|
||||
@@ -1727,6 +1727,7 @@ fn main() {
|
||||
append_local_permission_log,
|
||||
list_local_project_files,
|
||||
read_local_project_file,
|
||||
read_local_project_image_preview,
|
||||
write_local_project_file,
|
||||
delete_local_project_file,
|
||||
read_local_game_memory,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use base64::Engine as _;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -54083,3 +54084,89 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
#[test]
|
||||
fn local_project_image_preview_obeys_auto_file_read_policy() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "image-preview-policy", "图片预览策略项目")
|
||||
.expect("project init");
|
||||
fs::create_dir_all(root.join("assets")).expect("asset dir");
|
||||
let preview_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
|
||||
.expect("valid preview image");
|
||||
fs::write(root.join("assets/preview.png"), &preview_bytes).expect("preview image");
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"assets/preview.png",
|
||||
"ui-prototype",
|
||||
"image/png",
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("canvas-project-preview".to_string()),
|
||||
resource_id: Some("resource-preview".to_string()),
|
||||
asset_object_id: Some("asset-preview".to_string()),
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
},
|
||||
)
|
||||
.expect("register preview asset");
|
||||
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: Vec::new(),
|
||||
confirm_commands: vec!["file.read".to_string()],
|
||||
agent_policies: BTreeMap::new(),
|
||||
},
|
||||
)
|
||||
.expect("confirm policy");
|
||||
let confirm_error = read_local_project_image_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/preview.png".to_string(),
|
||||
)
|
||||
.expect_err("confirm policy blocks automatic preview");
|
||||
assert!(confirm_error.contains("要求用户确认:file.read"));
|
||||
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: vec!["file.read".to_string()],
|
||||
confirm_commands: Vec::new(),
|
||||
agent_policies: BTreeMap::new(),
|
||||
},
|
||||
)
|
||||
.expect("deny policy");
|
||||
let deny_error = read_local_project_image_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/preview.png".to_string(),
|
||||
)
|
||||
.expect_err("deny policy blocks preview");
|
||||
assert!(deny_error.contains("拒绝执行:file.read"));
|
||||
|
||||
write_project_permission_policy_at(
|
||||
&root,
|
||||
ProjectPermissionPolicy {
|
||||
denied_commands: Vec::new(),
|
||||
confirm_commands: Vec::new(),
|
||||
agent_policies: BTreeMap::new(),
|
||||
},
|
||||
)
|
||||
.expect("auto policy");
|
||||
let preview = read_local_project_image_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/preview.png".to_string(),
|
||||
)
|
||||
.expect("auto preview");
|
||||
assert_eq!(preview.media_type, "image/png");
|
||||
|
||||
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
|
||||
let unregistered_error = read_local_project_image_preview(
|
||||
root.to_string_lossy().into_owned(),
|
||||
"assets/unregistered.png".to_string(),
|
||||
)
|
||||
.expect_err("unregistered image rejected");
|
||||
assert!(unregistered_error.contains("只能预览已登记资源"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -3579,6 +3579,10 @@ iframe.preview-frame {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.game-resource-focus--art {
|
||||
width: min(760px, calc(100vw - 140px));
|
||||
}
|
||||
|
||||
.game-resource-focus-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3663,6 +3667,47 @@ iframe.preview-frame {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-image-preview {
|
||||
position: relative;
|
||||
display: grid;
|
||||
height: min(420px, calc(100dvh - 360px));
|
||||
min-height: 260px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ead8cf;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(45deg, #f1ebe7 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #f1ebe7 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #f1ebe7 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #f1ebe7 75%),
|
||||
#faf7f5;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
background-size: 16px 16px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-resource-image-preview img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.game-resource-image-preview p {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
color: #92776c;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-resource-document-body {
|
||||
height: max-content;
|
||||
min-height: 120px;
|
||||
|
||||
@@ -78,6 +78,23 @@ type ProjectResource = {
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type LocalProjectImagePreview = {
|
||||
path: string;
|
||||
mediaType: string;
|
||||
byteLen: number;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
type ImagePreviewState =
|
||||
| { status: 'idle'; resourceId: null }
|
||||
| { status: 'loading'; resourceId: string }
|
||||
| {
|
||||
status: 'loaded';
|
||||
resourceId: string;
|
||||
preview: LocalProjectImagePreview;
|
||||
}
|
||||
| { status: 'failed'; resourceId: string; error: string };
|
||||
|
||||
export type ProjectAgentResultSummary = {
|
||||
agentId: string;
|
||||
runId: string;
|
||||
@@ -213,6 +230,44 @@ function categoryFromResource(path: string, mediaType: string) {
|
||||
return 'version' as const;
|
||||
}
|
||||
|
||||
function isRasterImageResource(resource: ProjectResource) {
|
||||
const mediaType = resource.mediaType.toLowerCase();
|
||||
return (
|
||||
['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(
|
||||
mediaType,
|
||||
) || /\.(png|jpe?g|webp)$/iu.test(resource.path)
|
||||
);
|
||||
}
|
||||
|
||||
function imagePreviewErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('项目权限策略要求用户确认')) {
|
||||
return '当前项目策略要求先确认读取图片';
|
||||
}
|
||||
if (message.includes('项目权限策略拒绝执行')) {
|
||||
return '当前项目策略不允许读取图片';
|
||||
}
|
||||
if (message.includes('单张图片不能超过')) {
|
||||
return message.replace('image.inspect', '图片预览');
|
||||
}
|
||||
if (message.includes('只支持 PNG、JPEG 或 WEBP')) {
|
||||
return '暂时只能预览 PNG、JPEG 或 WEBP 图片';
|
||||
}
|
||||
if (message.includes('不能为空')) {
|
||||
return '图片文件为空,无法预览';
|
||||
}
|
||||
if (message.includes('发生漂移') || message.includes('发生替换')) {
|
||||
return '图片读取期间发生变化,请关闭后重试';
|
||||
}
|
||||
if (message.includes('图片尺寸过大')) {
|
||||
return '图片尺寸过大,暂时无法在客户端预览';
|
||||
}
|
||||
if (message.includes('图片结构无效')) {
|
||||
return '图片内容损坏,无法预览';
|
||||
}
|
||||
return '图片暂时无法读取,请关闭后重试';
|
||||
}
|
||||
|
||||
function taskDependencyDepth(
|
||||
task: GameCreationAppTaskState,
|
||||
taskById: Map<string, GameCreationAppTaskState>,
|
||||
@@ -534,6 +589,10 @@ export default function ProjectDevelopmentView({
|
||||
const [reorderNotice, setReorderNotice] = useState('');
|
||||
const [resourceDialogPosition, setResourceDialogPosition] =
|
||||
useState<Point | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<ImagePreviewState>({
|
||||
status: 'idle',
|
||||
resourceId: null,
|
||||
});
|
||||
const workbenchRef = useRef<HTMLElement>(null);
|
||||
const stageRef = useRef<HTMLElement>(null);
|
||||
const dockRef = useRef<HTMLElement>(null);
|
||||
@@ -587,6 +646,9 @@ export default function ProjectDevelopmentView({
|
||||
);
|
||||
const selectedResource =
|
||||
resources.find((resource) => resource.id === selectedResourceId) ?? null;
|
||||
const selectedResourceIsImage = Boolean(
|
||||
selectedResource && isRasterImageResource(selectedResource),
|
||||
);
|
||||
const hasRegisteredImageAssets = manifest.assets.some((asset) =>
|
||||
asset.mediaType.startsWith('image/'),
|
||||
);
|
||||
@@ -660,6 +722,50 @@ export default function ProjectDevelopmentView({
|
||||
return () => window.removeEventListener('keydown', closeOnEscape);
|
||||
}, [selectedResourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedResource || !selectedResourceIsImage) {
|
||||
setImagePreview({ status: 'idle', resourceId: null });
|
||||
return undefined;
|
||||
}
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setImagePreview({
|
||||
status: 'failed',
|
||||
resourceId: selectedResource.id,
|
||||
error: '图片预览需要在客户端内打开',
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setImagePreview({ status: 'loading', resourceId: selectedResource.id });
|
||||
void invoke<LocalProjectImagePreview>('read_local_project_image_preview', {
|
||||
projectPath,
|
||||
relativePath: selectedResource.path,
|
||||
})
|
||||
.then((preview) => {
|
||||
if (!cancelled) {
|
||||
setImagePreview({
|
||||
status: 'loaded',
|
||||
resourceId: selectedResource.id,
|
||||
preview,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) {
|
||||
setImagePreview({
|
||||
status: 'failed',
|
||||
resourceId: selectedResource.id,
|
||||
error: imagePreviewErrorMessage(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectPath, selectedResource, selectedResourceIsImage]);
|
||||
|
||||
const clampResourceDialogPosition = useCallback((x: number, y: number) => {
|
||||
const dialog = resourceDialogRef.current;
|
||||
const workbench = workbenchRef.current;
|
||||
@@ -1260,6 +1366,32 @@ export default function ProjectDevelopmentView({
|
||||
</button>
|
||||
</header>
|
||||
<div className="game-resource-focus-body">
|
||||
{selectedResourceIsImage ? (
|
||||
<div
|
||||
className="game-resource-image-preview"
|
||||
aria-label={`${selectedResource.label} 图片预览`}
|
||||
>
|
||||
{imagePreview.status === 'loaded' &&
|
||||
imagePreview.resourceId === selectedResource.id ? (
|
||||
<img
|
||||
src={imagePreview.preview.dataUrl}
|
||||
alt={`${selectedResource.label} 图片预览`}
|
||||
onError={() =>
|
||||
setImagePreview({
|
||||
status: 'failed',
|
||||
resourceId: selectedResource.id,
|
||||
error: '图片内容无法解码,请重新生成或替换该资源',
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : imagePreview.status === 'failed' &&
|
||||
imagePreview.resourceId === selectedResource.id ? (
|
||||
<p role="alert">{imagePreview.error}</p>
|
||||
) : (
|
||||
<p role="status">正在载入图片…</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<span>{selectedResource.path}</span>
|
||||
<small>{selectedResource.mediaType}</small>
|
||||
<small>{selectedResource.sourceLabel}</small>
|
||||
|
||||
@@ -842,7 +842,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('enables the run presentation after the real code prototype task completes', () => {
|
||||
it('enables the run presentation and renders registered images in the resource viewer', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'workbench-runnable',
|
||||
'可运行工作台',
|
||||
@@ -869,6 +869,23 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
taskId: 'art-asset-plan',
|
||||
},
|
||||
});
|
||||
const invoke = vi.fn(async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_local_project_image_preview') {
|
||||
expect(args).toEqual({
|
||||
projectPath: '/tmp/workbench-runnable',
|
||||
relativePath: 'assets/hero.png',
|
||||
});
|
||||
return {
|
||||
path: 'assets/hero.png',
|
||||
mediaType: 'image/png',
|
||||
byteLen: 12,
|
||||
dataUrl:
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
render(
|
||||
React.createElement(ProjectDevelopmentView, {
|
||||
@@ -910,6 +927,17 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /hero\.png/ }));
|
||||
expect(screen.getByLabelText('资源焦点')).not.toBeNull();
|
||||
expect(screen.getAllByText('assets/hero.png').length).toBeGreaterThan(0);
|
||||
const previewImage = await screen.findByRole('img', {
|
||||
name: 'hero.png 图片预览',
|
||||
});
|
||||
expect(previewImage.getAttribute('src')).toBe(
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledTimes(1);
|
||||
fireEvent.error(previewImage);
|
||||
expect(screen.getByRole('alert').textContent).toBe(
|
||||
'图片内容无法解码,请重新生成或替换该资源',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to embed a non-loopback game preview in the client workbench', () => {
|
||||
|
||||
@@ -256,6 +256,7 @@ game-project/
|
||||
- `canvas.project_sync` 复用 Genarrative External Editor API,读取用户平台 API Key 可访问的画板项目快照,通过 `/api/external/v1/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;默认 API base URL 为 `http://127.0.0.1:8082`,可用 Tauri 应用配置目录中的 `game-creator.config.json` 的 `editorApi.baseUrl` 覆盖,API Key 从同一配置的 `editorApi.apiKey` 读取,不写入项目文件、trace、manifest 或日志。
|
||||
- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再调用 `/api/external/v1/editor/images/generations`,携带 `projectId`、`assetFolderId`、`assetLabel`、`generationInputs.artSpec` 和 `canvasCompletion`,随后通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入项目文件、agent.db、trace、manifest 或日志;未配置 Key 或生成失败时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。
|
||||
- `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。
|
||||
- 项目工作台点击已登记图片时必须在客户端资源浮层中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。
|
||||
- `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:<file>` 作为可追踪 assetObjectId,不伪造后端资源行。
|
||||
|
||||
## GameAgent V1.0 项目开发工作台首版界面
|
||||
|
||||
Reference in New Issue
Block a user