完善Game Agent资源画板与图片精修链路

将资源依赖视图改为统一自由画板,并增加真实比例卡片和隐藏导航边界。

复用素材画布实现图片快速编辑、候选生成、导入、失败任务归档和正式图提交。

补齐原生事务恢复、服务身份确认、全屏失败遮罩及任务列表交互。

同步资源投影、布局、预览、前后端测试和技术文档。
This commit is contained in:
2026-08-21 15:03:59 +08:00
parent aaac44a622
commit a86b7db951
29 changed files with 8060 additions and 5105 deletions
@@ -115,12 +115,15 @@ const rustSharedContractSource = fs.readFileSync(
);
const allowedUncalledTauriCommands = [
'archive_failed_local_project_resource_edit',
'archive_failed_local_project_asset_canvas_generation',
'chat_with_game_creator_agent',
'commit_local_project_asset',
'commit_local_project_asset_canvas_candidate',
'confirm_local_project_asset_canvas_generation_service_identity',
'create_local_project_asset_canvas_draft',
'discard_local_project_asset_canvas_draft',
'generate_local_project_asset_canvas_image',
'import_local_project_asset_canvas_images',
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'read_local_project_asset_canvas_draft',
@@ -382,6 +382,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,
@@ -398,6 +437,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,
@@ -487,6 +534,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,
@@ -2284,8 +2284,10 @@ 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,
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,
@@ -2293,6 +2295,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
@@ -6144,8 +6144,7 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
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");
let serialized_preview = serde_json::to_value(&preview).expect("serialize image preview");
assert_eq!(serialized_preview["pixelWidth"], 1);
assert_eq!(serialized_preview["pixelHeight"], 1);
File diff suppressed because it is too large Load Diff
@@ -1,469 +0,0 @@
.asset-canvas-surface {
--image-canvas-brand-accent: var(--platform-button-primary-solid);
--image-canvas-brand-accent-strong: var(--platform-text-strong);
--image-canvas-brand-border-soft: var(--platform-icon-border);
--image-canvas-brand-border-strong: var(--platform-nav-active-border);
--image-canvas-brand-focus-ring: var(--platform-input-focus-ring);
--image-canvas-brand-soft: var(--platform-nav-active-fill);
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
width: 100%;
height: 100%;
min-height: 0;
container-type: inline-size;
overflow: hidden;
border-radius: inherit;
background: var(--platform-overlay-fill);
color: var(--platform-text-strong);
}
.asset-canvas-surface--loading {
place-items: center;
}
.asset-canvas-surface__toolbar-shell {
z-index: 4;
display: flex;
flex-direction: column;
min-width: 0;
align-items: stretch;
gap: 8px;
padding: 8px 10px 9px;
border-bottom: 1px solid var(--platform-line-soft);
background: var(--platform-desktop-topbar-fill);
}
.asset-canvas-surface__toolbar {
width: 100%;
max-width: 100%;
flex: 0 0 auto;
padding-block: 1px 3px;
}
.asset-canvas-surface__savebar {
display: grid;
width: 100%;
max-width: 620px;
grid-template-columns:
minmax(150px, 1.35fr) minmax(118px, 1fr) minmax(88px, 0.55fr)
max-content;
flex: 0 0 auto;
min-width: 0;
align-self: flex-end;
align-items: end;
gap: 8px;
margin-inline-start: auto;
}
.asset-canvas-surface__save-field {
display: grid;
min-width: 0;
gap: 3px;
}
.asset-canvas-surface__save-field > span {
color: var(--platform-text-muted);
font-size: 11px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.asset-canvas-surface__savebar input,
.asset-canvas-surface__savebar select,
.asset-canvas-surface__generation-form select {
width: 100%;
min-width: 0;
min-height: 34px;
border: 1px solid var(--platform-surface-border);
border-radius: 8px;
background: var(--platform-input-fill);
color: var(--platform-text-base);
font: inherit;
padding: 0 8px;
}
.asset-canvas-surface__savebar input,
.asset-canvas-surface__savebar select {
font-size: 12px;
font-weight: 600;
line-height: 1.25;
}
.asset-canvas-surface__savebar input:focus-visible,
.asset-canvas-surface__savebar select:focus-visible,
.asset-canvas-surface__generation-form select:focus-visible,
.asset-canvas-surface__generation-form textarea:focus-visible {
border-color: var(--platform-surface-hover-border);
outline: none;
box-shadow: 0 0 0 3px var(--platform-input-focus-ring);
}
.asset-canvas-surface__import-input {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.asset-canvas-surface .asset-canvas-surface__primary-action,
.asset-canvas-surface .asset-canvas-surface__save {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
box-shadow: var(--platform-nav-active-shadow);
}
.asset-canvas-surface .asset-canvas-surface__save {
min-width: max-content;
min-height: 34px;
align-self: end;
white-space: nowrap;
}
.asset-canvas-surface
.asset-canvas-surface__primary-action:not(:disabled):hover,
.asset-canvas-surface .asset-canvas-surface__save:not(:disabled):hover {
border-color: var(--platform-button-primary-border);
background: var(--platform-button-primary-fill);
color: var(--platform-button-primary-text);
}
.asset-canvas-surface[data-state='canvas.generating']
.asset-canvas-surface__viewport,
.asset-canvas-surface[data-state='canvas.generating']
.asset-canvas-surface__viewport-tools {
pointer-events: none;
}
.asset-canvas-surface__dialog-backdrop {
position: absolute;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: color-mix(
in srgb,
var(--platform-text-strong) 36%,
transparent
);
backdrop-filter: blur(5px);
}
.asset-canvas-surface__generation-dialog {
display: grid;
width: min(560px, 100%);
max-height: calc(100% - 32px);
min-height: 0;
overflow: auto;
border: 1px solid var(--platform-modal-border);
border-radius: 16px;
background: var(--platform-modal-fill);
color: var(--platform-text-strong);
box-shadow: var(--platform-panel-shadow);
}
.asset-canvas-surface__generation-dialog > header,
.asset-canvas-surface__generation-dialog footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
}
.asset-canvas-surface__generation-dialog > header {
border-bottom: 1px solid var(--platform-line-soft);
}
.asset-canvas-surface__generation-form,
.asset-canvas-surface__generation-confirmation {
display: grid;
gap: 14px;
padding: 16px;
}
.asset-canvas-surface__generation-form label {
display: grid;
gap: 6px;
color: var(--platform-text-base);
font-size: 13px;
font-weight: 700;
}
.asset-canvas-surface__generation-form textarea {
box-sizing: border-box;
width: 100%;
resize: vertical;
border: 1px solid var(--platform-surface-border);
border-radius: 10px;
padding: 10px;
background: var(--platform-input-fill);
color: var(--platform-text-strong);
font: inherit;
}
.asset-canvas-surface__generation-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.asset-canvas-surface__generation-form fieldset {
display: grid;
max-height: 180px;
gap: 8px;
overflow: auto;
border: 1px solid var(--platform-subpanel-border);
border-radius: 10px;
padding: 10px;
}
.asset-canvas-surface__generation-form fieldset label {
display: flex;
align-items: center;
font-weight: 500;
}
.asset-canvas-surface__generation-form fieldset input {
min-height: auto;
}
.asset-canvas-surface__generation-dialog footer {
justify-content: flex-end;
padding: 0;
}
.asset-canvas-surface__viewport {
grid-row: 2;
min-height: 0;
background-color: var(--platform-neutral-bg);
background-image: radial-gradient(
var(--platform-line-soft) 1px,
transparent 1px
);
background-size: 18px 18px;
}
.asset-canvas-surface__operation-overlay {
z-index: 12;
grid-row: 2;
grid-column: 1;
display: grid;
min-width: 0;
min-height: 0;
place-items: center;
overflow: auto;
padding: 20px;
background: color-mix(
in srgb,
var(--platform-text-strong) 30%,
transparent
);
backdrop-filter: blur(4px);
}
.asset-canvas-surface__operation-card {
display: grid;
width: min(440px, 100%);
max-height: 100%;
min-height: 0;
gap: 14px;
overflow: auto;
border: 1px solid var(--platform-modal-border);
border-radius: 18px;
padding: 22px;
background: var(--platform-modal-fill);
color: var(--platform-text-strong);
box-shadow: var(--platform-panel-shadow);
}
.asset-canvas-surface__operation-card strong {
font-size: 20px;
}
.asset-canvas-surface__operation-card p {
margin: 0;
color: var(--platform-text-base);
line-height: 1.55;
overflow-wrap: anywhere;
}
.asset-canvas-surface__operation-card progress {
width: 100%;
height: 8px;
overflow: hidden;
border: 0;
border-radius: 999px;
accent-color: var(--platform-accent);
}
.asset-canvas-surface__operation-kicker {
color: var(--platform-warm-text);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
}
.asset-canvas-surface__operation-card--failed {
border-color: var(--platform-button-danger-border);
}
.asset-canvas-surface__operation-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.asset-canvas-surface__resize-handle {
position: absolute;
right: -8px;
bottom: -8px;
width: 16px;
height: 16px;
border: 2px solid var(--platform-input-fill);
border-radius: 50%;
background: var(--platform-accent);
cursor: nwse-resize;
}
.asset-canvas-surface__layer-hit-target {
display: block;
width: 100%;
height: 100%;
padding: 0;
overflow: hidden;
border: 0;
background: transparent;
color: inherit;
cursor: inherit;
}
.asset-canvas-surface__layer-hit-target:focus-visible,
.asset-canvas-surface__resize-handle:focus-visible {
outline: 3px solid var(--platform-focus-ring);
outline-offset: 3px;
}
.asset-canvas-surface__viewport-tools {
position: absolute;
z-index: 5;
right: 14px;
bottom: 46px;
display: grid;
gap: 8px;
}
.asset-canvas-surface__zoom {
max-width: min(100%, 280px);
justify-content: flex-end;
}
.asset-canvas-surface .genarrative-image-canvas__minimap {
width: 132px;
height: 84px;
padding: 0;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
background: var(--platform-input-fill);
box-shadow: var(--platform-nav-active-shadow);
}
.asset-canvas-surface .genarrative-image-canvas__minimap-layer {
background: var(--platform-warm-bg);
}
.asset-canvas-surface .genarrative-image-canvas__minimap-viewport {
border: 1px solid var(--platform-accent);
}
.asset-canvas-surface__status {
z-index: 4;
display: flex;
min-height: 34px;
max-height: 96px;
align-items: center;
flex-wrap: wrap;
gap: 12px;
overflow-y: auto;
padding: 8px 12px;
border-top: 1px solid var(--platform-line-soft);
background: var(--platform-desktop-topbar-fill);
color: var(--platform-text-base);
font-size: 12px;
line-height: 1.4;
scrollbar-gutter: stable;
}
.asset-canvas-surface__status > span {
min-width: 0;
overflow-wrap: anywhere;
}
.asset-canvas-surface__status > span:first-child {
color: var(--platform-text-strong);
font-weight: 800;
}
@media (max-width: 760px) {
.asset-canvas-surface {
height: 100%;
min-height: 0;
border-radius: 0;
}
.asset-canvas-surface__toolbar-shell {
padding-inline: 8px;
}
.asset-canvas-surface__savebar {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.asset-canvas-surface__save-field:first-child {
grid-column: 1 / -1;
}
.asset-canvas-surface__save {
grid-column: 1 / -1;
width: 100%;
}
.asset-canvas-surface__viewport-tools {
right: 8px;
bottom: 52px;
}
.asset-canvas-surface__generation-options {
grid-template-columns: 1fr;
}
.asset-canvas-surface__operation-overlay {
place-items: end stretch;
padding: 10px;
}
.asset-canvas-surface__operation-card {
width: auto;
border-radius: 16px;
padding: 18px;
}
}
@container (max-width: 620px) {
.asset-canvas-surface__savebar {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.asset-canvas-surface__save-field:first-child,
.asset-canvas-surface__save {
grid-column: 1 / -1;
width: 100%;
}
}
File diff suppressed because it is too large Load Diff
@@ -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,11 @@ type StagedImageResult = {
type GenerationCommandResult = {
generation: ImageCanvasGenerationRecord;
images: [];
commit: ImageCanvasGenerationCommitResult;
draft: ImageCanvasDraft;
images: Array<{
mediaRef: ImageCanvasMediaRef;
resourceId: string;
}>;
};
type GenerationProgressEvent = ImageCanvasGenerationProgress & {
@@ -306,6 +315,29 @@ export function createTauriImageCanvasHostAdapter(input: {
);
};
const generation: ImageCanvasGenerationPort = {
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 +369,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 +530,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 +629,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(
+99 -1
View File
@@ -4982,6 +4982,17 @@ iframe.preview-frame {
background-size: 18px 18px;
}
.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 {
position: relative;
display: grid;
@@ -4991,6 +5002,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;
}
@@ -5017,6 +5034,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;
@@ -5149,6 +5195,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;
@@ -5362,15 +5431,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 {
@@ -38,7 +38,7 @@ type RectLookup = {
export type ResourceDependencyOverlayProps = {
graph: ProjectResourceGraph;
positions: readonly ProjectResourceCanvasPosition[];
section: ProjectResourceCanvasSection;
section?: ProjectResourceCanvasSection;
visibleResourceIds: ReadonlySet<string>;
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId;
geometryRevision?: string;
@@ -492,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;
@@ -541,13 +545,13 @@ 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)
) {
@@ -590,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],
);
@@ -670,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
@@ -1,3 +1,9 @@
import {
type CanvasViewport,
MAX_SCALE,
MIN_SCALE,
} from '@genarrative/image-canvas-core';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
@@ -23,6 +29,7 @@ export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
export const RESOURCE_CANVAS_BOUNDARY_PADDING = 96;
const sectionOrder: ProjectResourceCanvasSection[] = [
'code',
@@ -42,6 +49,95 @@ export type ResourceCanvasImageDimensions = {
pixelHeight: number;
};
export type ResourceCanvasNavigationBounds = {
x: number;
y: number;
width: number;
height: number;
};
function clampResourceCanvasNumber(
value: number,
minimum: number,
maximum: number,
) {
return Math.min(maximum, Math.max(minimum, value));
}
/**
* Keeps the dependency canvas inside the invisible boundary formed by every
* resource plus a fixed screen-space safety margin. The minimum zoom is the
* scale at which the whole boundary fits; panning never lets the resource
* union move beyond the safety margin on either axis.
*/
export function constrainResourceCanvasViewport({
bounds,
canvasSize,
padding = RESOURCE_CANVAS_BOUNDARY_PADDING,
viewport,
}: {
bounds: ResourceCanvasNavigationBounds;
canvasSize: { width: number; height: number };
padding?: number;
viewport: CanvasViewport;
}): CanvasViewport {
const canvasWidth = Math.max(
1,
Number.isFinite(canvasSize.width) ? canvasSize.width : 1,
);
const canvasHeight = Math.max(
1,
Number.isFinite(canvasSize.height) ? canvasSize.height : 1,
);
const boundsX = Number.isFinite(bounds.x) ? bounds.x : 0;
const boundsY = Number.isFinite(bounds.y) ? bounds.y : 0;
const boundsWidth = Math.max(
1,
Number.isFinite(bounds.width) ? bounds.width : 1,
);
const boundsHeight = Math.max(
1,
Number.isFinite(bounds.height) ? bounds.height : 1,
);
const normalizedPadding = Math.max(0, Number.isFinite(padding) ? padding : 0);
const horizontalPadding = Math.min(normalizedPadding, (canvasWidth - 1) / 2);
const verticalPadding = Math.min(normalizedPadding, (canvasHeight - 1) / 2);
const availableWidth = Math.max(1, canvasWidth - horizontalPadding * 2);
const availableHeight = Math.max(1, canvasHeight - verticalPadding * 2);
const minimumScale = clampResourceCanvasNumber(
Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight),
MIN_SCALE,
MAX_SCALE,
);
const scale = clampResourceCanvasNumber(
Number.isFinite(viewport.scale) ? viewport.scale : minimumScale,
minimumScale,
MAX_SCALE,
);
const minimumX =
canvasWidth - horizontalPadding - (boundsX + boundsWidth) * scale;
const maximumX = horizontalPadding - boundsX * scale;
const minimumY =
canvasHeight - verticalPadding - (boundsY + boundsHeight) * scale;
const maximumY = verticalPadding - boundsY * scale;
const centeredX = canvasWidth / 2 - (boundsX + boundsWidth / 2) * scale;
const centeredY = canvasHeight / 2 - (boundsY + boundsHeight / 2) * scale;
const candidateX = Number.isFinite(viewport.x) ? viewport.x : centeredX;
const candidateY = Number.isFinite(viewport.y) ? viewport.y : centeredY;
return {
x:
minimumX <= maximumX
? clampResourceCanvasNumber(candidateX, minimumX, maximumX)
: centeredX,
y:
minimumY <= maximumY
? clampResourceCanvasNumber(candidateY, minimumY, maximumY)
: centeredY,
scale,
};
}
export type ResourceCanvasCardSizeByResourceId = ReadonlyMap<
string,
ResourceCanvasCardSize
@@ -74,6 +74,8 @@ export function createResourceSignature(resources: ResourceCanvasItem[]) {
resource.label,
resource.dependencyDepth,
resource.mediaType,
resource.cardSize?.width ?? null,
resource.cardSize?.height ?? null,
]),
)
.sort()
@@ -223,8 +223,15 @@ export function useProjectResourceCardPreviews(input: {
const [previews, setPreviews] = useState<
Map<string, ProjectResourceCardPreviewState>
>(new Map());
const [imageDimensionsByIdentity, setImageDimensionsByIdentity] = useState<
Map<
string,
NonNullable<ReturnType<typeof projectResourceCardPreviewImageDimensions>>
>
>(new Map());
const [initialScopeId] = useState(createProjectResourcePreviewScopeId);
const previewsRef = useRef(previews);
const imageDimensionsByIdentityRef = useRef(imageDimensionsByIdentity);
const scopeKeyRef = useRef(scopeKey);
const scopeIdRef = useRef(initialScopeId);
const scopeInitializedRef = useRef(false);
@@ -286,6 +293,19 @@ export function useProjectResourceCardPreviews(input: {
materialized?: MaterializedPreview,
) => {
const next = new Map(previewsRef.current);
if (state.status === 'loaded') {
const dimensions = projectResourceCardPreviewImageDimensions(
state.preview,
);
if (dimensions) {
const nextImageDimensions = new Map(
imageDimensionsByIdentityRef.current,
);
nextImageDimensions.set(identity, dimensions);
imageDimensionsByIdentityRef.current = nextImageDimensions;
setImageDimensionsByIdentity(nextImageDimensions);
}
}
cacheOrderRef.current.delete(identity);
disposeCachedPreview(identity);
next.set(identity, state);
@@ -594,6 +614,8 @@ export function useProjectResourceCardPreviews(input: {
protectedIdentityRef.current = null;
previewsRef.current = new Map();
setPreviews(new Map());
imageDimensionsByIdentityRef.current = new Map();
setImageDimensionsByIdentity(new Map());
}, [disposeAllCachedPreviews, scopeKey]);
useEffect(() => {
@@ -612,6 +634,16 @@ export function useProjectResourceCardPreviews(input: {
previewsRef.current = next;
setPreviews(next);
}
const currentImageDimensions = imageDimensionsByIdentityRef.current;
const nextImageDimensions = new Map(
Array.from(currentImageDimensions).filter(([identity]) =>
validIdentities.has(identity),
),
);
if (nextImageDimensions.size !== currentImageDimensions.size) {
imageDimensionsByIdentityRef.current = nextImageDimensions;
setImageDimensionsByIdentity(nextImageDimensions);
}
}, [disposeCachedPreview, identityByResourceId]);
useEffect(() => {
@@ -653,23 +685,19 @@ export function useProjectResourceCardPreviews(input: {
>();
for (const resource of input.resources) {
const identity = identityByResourceId.get(resource.id);
const state = identity ? previews.get(identity) : undefined;
const kind = projectResourceCardPreviewKind(resource);
if (
state?.status !== 'loaded' ||
(kind !== 'raster-image' && kind !== 'media-image')
) {
if (kind !== 'raster-image' && kind !== 'media-image') {
continue;
}
const dimensions = projectResourceCardPreviewImageDimensions(
state.preview,
);
const dimensions = identity
? imageDimensionsByIdentity.get(identity)
: undefined;
if (dimensions) {
result.set(resource.id, dimensions);
}
}
return result;
}, [identityByResourceId, input.resources, previews]);
}, [identityByResourceId, imageDimensionsByIdentity, input.resources]);
useEffect(
() => () => {
@@ -193,6 +193,36 @@ describe('ResourceDependencyOverlay', () => {
expect(overlay.querySelector('marker[id$="-task-flow-arrow"]')).toBeNull();
});
it('在统一 world 模式下渲染跨分类引用,并使用稳定的 world 标识', async () => {
const graph = graphFixture();
const positions = [
position('source:one', 0, 0, 'art'),
position('target:one', 220, 0, 'document'),
];
render(
React.createElement(
'div',
null,
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
visibleResourceIds: new Set(
positions.map(({ resourceId }) => resourceId),
),
}),
),
);
const overlay = await screen.findByTestId(
'resource-dependency-overlay-world',
);
expect(overlay.getAttribute('data-resource-section')).toBeNull();
expect(
overlay.querySelector(
'[data-edge-kind="asset-reference"][data-source-resource-id="source:one"][data-target-resource-id="target:one"]',
),
).not.toBeNull();
});
it('omits task flows and cross-section exact references', async () => {
const graph = graphFixture();
const positions = [
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -81,30 +81,38 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
if (!base) throw new Error('missing manifest fixture');
canvasFixture.sequence += 1;
canvasFixture.revision += 1;
const assetId = `canvas-output-${canvasFixture.sequence}`;
const assetId =
props.scope.intent === 'refine' && props.scope.sourceAssetId
? props.scope.sourceAssetId
: `canvas-output-${canvasFixture.sequence}`;
const commitId = `commit-${canvasFixture.sequence}`;
const sourceResourceId = props.scope.sourceAssetId
? `asset:${props.scope.sourceAssetId}`
: null;
const existingAsset = base.assets.find(
(asset) => asset.id === assetId,
);
const committedAsset = {
id: assetId,
kind: existingAsset?.kind ?? 'art-image',
mediaType: 'image/png',
localPath:
props.scope.intent === 'refine'
? `assets/canvas/${assetId}--${canvasFixture.sequence}.png`
: `assets/${assetId}.png`,
source:
existingAsset?.source ??
({
kind: 'canvas' as const,
taskId: null,
resourceId: `canvas-resource-${canvasFixture.sequence}`,
referenceResourceIds: [],
} as const),
};
const nextManifest = {
...base,
assets: [
...base.assets,
{
id: assetId,
kind: 'art-image',
mediaType: 'image/png',
localPath: `assets/${assetId}.png`,
source: {
kind: 'canvas' as const,
taskId: null,
resourceId: `canvas-resource-${canvasFixture.sequence}`,
referenceResourceIds: sourceResourceId
? [sourceResourceId]
: [],
},
},
],
assets: existingAsset
? base.assets.map((asset) =>
asset.id === assetId ? committedAsset : asset,
)
: [...base.assets, committedAsset],
};
canvasFixture.manifest = nextManifest;
props.onSaveAttempt?.({
@@ -700,58 +708,65 @@ describe('project resource live canvas integration', () => {
).disabled,
).toBe(true);
fireEvent.click(sourceCard);
const sourceDetails = screen.getByRole('dialog', {
name: /source-art\.png/,
});
expect(sourceDetails.closest('.game-workbench-stage')).toBe(
screen.getByLabelText('项目主视窗'),
);
expect(sourceDetails.hasAttribute('aria-modal')).toBe(false);
expect(document.querySelector('.game-resource-focus-backdrop')).toBeNull();
expect(screen.getByRole('button', { name: '新增资源' })).not.toBeNull();
expect(
screen
.getByRole('button', { name: '按依赖' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(screen.getByRole('button', { name: '按类型' })).not.toBeNull();
expect(screen.getByRole('button', { name: '复位资源画布' })).not.toBeNull();
expect(screen.getByLabelText('资源依赖视图').hasAttribute('inert')).toBe(
false,
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:source-art:source-art:art-image');
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(
await screen.findByRole('region', { name: /source-art\.png/ }),
await screen.findByRole('dialog', { name: /source-art\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
fireEvent.click(
await screen.findByRole('button', { name: '完成测试保存' }),
);
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
expect(screen.queryByRole('dialog', { name: /canvas-output/ })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
const updatedSource = await screen.findByRole('button', {
name: /source-art--1.png/,
});
fireEvent.click(updatedSource);
expect(
await screen.findByRole('region', { name: /canvas-output-1\.png/ }),
await screen.findByRole('dialog', { name: /source-art--1.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', { name: /source-art\.png/ }),
).not.toBeNull();
layoutWrites.every(
(write) =>
!(write.positions as Array<{ resourceId: string }>).some(
(position) => position.resourceId === 'asset:canvas-output-1',
),
),
).toBe(true);
expect(
await screen.findByText(/canvas-output-1\.png 引用 source-art\.png/),
).not.toBeNull();
await waitFor(() => {
expect(
layoutWrites.some(
(write) =>
write.mode === 'dependency' &&
(write.positions as Array<{ resourceId: string }>).some(
(position) => position.resourceId === 'asset:canvas-output-1',
),
),
).toBe(true);
expect(
layoutWrites.some(
(write) =>
write.mode === 'type' &&
(write.positions as Array<{ resourceId: string }>).some(
(position) => position.resourceId === 'asset:canvas-output-1',
),
),
).toBe(true);
});
expect(
graphReads.some((read) => {
graphReads.every((read) => {
const resourceIds = (
read.resources as Array<{ resourceId: string }>
).map((resource) => resource.resourceId);
return (
resourceIds.includes('asset:source-art') &&
resourceIds.includes('asset:canvas-output-1')
!resourceIds.includes('asset:canvas-output-1')
);
}),
).toBe(true);
@@ -786,7 +801,7 @@ describe('project resource live canvas integration', () => {
});
});
it('keeps an existing search when an edited result is hidden and locates only after the explicit action', async () => {
it('keeps an existing search while replacing the same final-image resource', async () => {
installTauri();
render(<LiveWorkbench />);
const search = await screen.findByLabelText('搜索项目资源');
@@ -799,19 +814,16 @@ describe('project resource live canvas integration', () => {
await screen.findByRole('button', { name: '完成测试保存' }),
);
expect(await screen.findByLabelText('测试素材创作画布')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
const restoredSearch = (await screen.findByLabelText(
'搜索项目资源',
)) as HTMLInputElement;
expect(restoredSearch.value).toBe('source-art');
expect(
await screen.findByText('新资源已保存,但被当前搜索条件隐藏'),
await screen.findByRole('button', { name: /source-art--1.png/ }),
).not.toBeNull();
expect((search as HTMLInputElement).value).toBe('source-art');
fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' }));
expect(
await screen.findByRole('region', { name: /canvas-output-1\.png/ }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
((await screen.findByLabelText('搜索项目资源')) as HTMLInputElement)
.value,
).toBe('');
expect(screen.queryByText('新资源已保存,但被当前搜索条件隐藏')).toBeNull();
});
it('derives a document non-destructively and retries with the original operation identity', async () => {
@@ -837,7 +849,7 @@ describe('project resource live canvas integration', () => {
expect(deriveCalls[1]).not.toHaveProperty('apiKey');
const operationId = String(deriveCalls[1]?.operationId);
expect(
await screen.findByRole('region', {
await screen.findByRole('dialog', {
name: new RegExp(operationId, 'u'),
}),
).not.toBeNull();
@@ -887,7 +899,7 @@ describe('project resource live canvas integration', () => {
expect(resumeCalls[0]).not.toHaveProperty('endpoint');
expect(resumeCalls[0]).not.toHaveProperty('idempotencyKey');
expect(
await screen.findByRole('region', {
await screen.findByRole('dialog', {
name: /recovered-rules\.md/,
}),
).not.toBeNull();
@@ -5,6 +5,7 @@ import {
isSafeProjectResourceCanvasCoordinate,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
constrainResourceCanvasViewport,
createEmptyResourceCanvasLayout,
createResourceCanvasCardSizeByResourceId,
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
@@ -56,6 +57,46 @@ function positionById(
}
describe('resource canvas layout model', () => {
it('clamps dependency-canvas panning to the resource union and safety margin', () => {
const common = {
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
canvasSize: { width: 800, height: 600 },
};
expect(
constrainResourceCanvasViewport({
...common,
viewport: { x: 2_000, y: 2_000, scale: 1 },
}),
).toEqual({ x: 96, y: 96, scale: 1 });
expect(
constrainResourceCanvasViewport({
...common,
viewport: { x: -2_000, y: -2_000, scale: 1 },
}),
).toEqual({ x: -296, y: -296, scale: 1 });
});
it('stops zooming out once all resources and the hidden margin fit', () => {
expect(
constrainResourceCanvasViewport({
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
canvasSize: { width: 800, height: 600 },
viewport: { x: -800, y: 900, scale: 0.001 },
}),
).toEqual({ x: 145, y: 96, scale: 0.51 });
});
it('centers a resource boundary smaller than the viewport instead of allowing blank panning', () => {
expect(
constrainResourceCanvasViewport({
bounds: { x: 20, y: 30, width: 200, height: 100 },
canvasSize: { width: 800, height: 600 },
viewport: { x: -5_000, y: 5_000, scale: 1 },
}),
).toEqual({ x: 280, y: 220, scale: 1 });
});
it('creates non-overlapping defaults and keeps the two modes independent', () => {
const resources = [
resource('a', 'art', 0),
@@ -52,9 +52,9 @@
#### 3.3.1 资源管理三个串行阶段
1. 阶段一“资源卡本体化”:`dependency / type` 两种布局共用同一个资源卡组件和预览调度器。图片与安全 SVG 直接显示主体并保留透明棋盘底;视频显示首个可解码画面并可在卡内播放;音频使用清晰的音频视觉和独立播放键;文档显示安全纯文本摘要或稳定类型占位;项目版本不伪造媒体,只显示稳定版本视觉和必要的父子 / 绑定状态。中央详情以元数据、Rust 权威依赖关系和版本信息为主,不重复放大图片、SVG 或视频;文档正文和按用户意图读取的音频控件位于元数据之后
2. 阶段二“分区独立缩放”(已完成)同时包含互不替代的分区可视高度和内容倍率。文档、项目版本、美术资源、音乐音效资源的标题栏分别保留高度减小 / 恢复 / 增大,并新增 `50%..200%` 内容缩放减小 / 百分比复位 / 增大;触摸板捏合、`Ctrl/Cmd + wheel` 与按钮使用同一倍率模型,普通双指滚动仍只滚动。高度与倍率都以 `projectId + layoutMode + category` 隔离并只保留在当前会话内存;倍率以分区 viewport 中的手势中心为锚点,逻辑 `x / y``180×128` 本体尺寸和媒体比例不变。最小高度固定容纳标题操作与至少一排卡片,最大高度以中央资源画布当前可用高度为上限。分区保持正常文档流,后续分区由外层资源画布滚动访问,单区内容在缩放后仍只在该区内部滚动。两类表现态都不修改 manifest、`game-creator-resource-layout.v1`、布局 CAS 或资源身份,不重建媒体控件、不抢走焦点,也不恢复卡片拖动。
3. 阶段三“依赖布局聚类”(已完成):仅在 `dependency` 模式中,按固定资源分对当前 Rust 只读图返回的同类型精确引用边和同类型聚合任务流做弱连通分组;任务流以流节点连接其成员,绝不展开成资源笛卡尔积,但只作为布局邻近提示,不绘制虚线、不进入画布关系说明。`dependencyDepth` 是唯一横向层级权威;每个相关簇先按“最小深度 -> 最小稳定资源 ID”排序,再在同层进行固定两轮由左至右、由右至左的中位数扫描,平局回退稳定资源 ID;同 SCC/环成员保持连续,环后资源继续按 Rust 深度推进。孤立资源是稳定单例组,紧凑排在相关簇之后,簇之间保持固定留白。跨分类 read model 关系不改变 Rust 业务真相,但不得进入前端布局邻接、关系说明或 SVG;搜索只隐藏卡片和橙色精确引用边,不重算组或压缩坐标。历史 `manuallyPlaced=true` 坐标继续原样保留并参与自动坐标避让。聚类是可派生表现,不新增 manifest 字段、sidecar schema、SpacetimeDB 表或第二套依赖真相。
1. 阶段一“资源卡本体化”:`dependency / type` 两种布局共用同一个资源卡组件和预览调度器。图片与安全 SVG 直接显示主体并保留透明棋盘底;图片卡按真实纵横比在统一边界内计算展示矩形,视频、音频、文档和项目版本继续使用稳定固定卡片。资源详情是叠加在原画板上的非模态独立卡片,以元数据、Rust 权威依赖关系和版本信息为主;打开详情不得卸载“新增资源 / 按依赖 / 按类型”、资源卡、依赖连线或重置 viewport、搜索和排序模式
2. 阶段二“分区独立缩放”(已完成)只适用于 `type` 模式,同时包含互不替代的分区可视高度和内容倍率。文档、项目版本、美术资源、音乐音效资源的标题栏分别保留高度减小 / 恢复 / 增大,并新增 `50%..200%` 内容缩放减小 / 百分比复位 / 增大;触摸板捏合、`Ctrl/Cmd + wheel` 与按钮使用同一倍率模型,普通双指滚动仍只滚动。高度与倍率都以 `projectId + type + category` 隔离并只保留在当前会话内存;倍率以分区 viewport 中的手势中心为锚点,逻辑 `x / y` 和媒体比例不变;非图片资源维持 `180×128`,美术图片按真实比例和统一边界计算本体矩形。最小高度固定容纳标题操作与至少一排卡片,最大高度以中央资源画布当前可用高度为上限。分区保持正常文档流,后续分区由外层资源画布滚动访问,单区内容在缩放后仍只在该区内部滚动。两类表现态都不修改 manifest、`game-creator-resource-layout.v1`、布局 CAS 或资源身份,不重建媒体控件、不抢走焦点,也不恢复卡片拖动。`dependency` 模式使用统一世界自由画板,不展示这些分区滚动与缩放操作。
3. 阶段三“依赖布局聚类”(已完成):仅在 `dependency` 模式中,按固定资源分对当前 Rust 只读图返回的同类型精确引用边和同类型聚合任务流做弱连通分组;任务流以流节点连接其成员,绝不展开成资源笛卡尔积,但只作为布局邻近提示,不绘制虚线、不进入画布关系说明。`dependencyDepth` 是唯一横向层级权威;每个相关簇先按“最小深度 -> 最小稳定资源 ID”排序,再在同层进行固定两轮由左至右、由右至左的中位数扫描,平局回退稳定资源 ID;同 SCC/环成员保持连续,环后资源继续按 Rust 深度推进。孤立资源是稳定单例组,紧凑排在相关簇之后,簇之间保持固定留白。跨分类精确引用不参与前端布局邻接,但会在统一世界 SVG 中显示并进入同源的无障碍关系说明;搜索只隐藏卡片及任一端点被隐藏的橙色精确引用边,不重算组或压缩坐标。历史 `manuallyPlaced=true` 坐标继续原样保留并参与自动坐标避让。聚类是可派生表现,不新增 manifest 字段、sidecar schema、SpacetimeDB 表或第二套依赖真相。
#### 3.3.2 本体化资源卡交互与性能合同
@@ -110,7 +110,7 @@
- 客户端 UI 对齐采用“同视觉、同组件、保留四区布局”,不复制主站整页布局。资源总览、运行视图和 Supervisor 对话继续是 Game Agent 工作台独有语义。
- 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。
- 普通用户界面不默认展示内部错误码或本机绝对路径,也不展示 External Editor Base URL 或 Developer API Key;官方服务地址由客户端构建固定,远端编辑自动使用当前陶泥儿登录态。仅独立 standalone game-chat release 或显式高级自定义模式在隔离配置页维护 External v1 URL/Key,确需诊断的信息进入受控详情或开发模式。
- 素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择;精修资源继承源用途且不可改。导出格式继续限定 PNG/JPEG/WebP。工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。
- 创建模式素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择。图片精修继承源名称和用途,不显示创建模式保存设置;候选图片只从选中图片的“设为最终图”提交。精修顶栏只保留返回、导入、定位当前最终图、撤销和重做,删除进入图片上下文工具栏,通用 AI 生成只保留给创建模式。图片输出统一使用 PNG;创建模式工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。
### 3.8 现有 Godot 项目
@@ -175,14 +175,14 @@ idle -> focused(document|art|audio|version) -> idle
```
- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。
- 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
- 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
- mentor 最新决定:资源聚焦不提供工具栏,也不提供工具侧边栏。
- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源编辑只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本。
- 资源聚焦不提供通用工具栏或工具侧边栏;图片聚焦态允许一个明确的“精修资源”业务动作进入素材创作无限画布,该动作不是在聚焦容器中内嵌编辑器或恢复通用工具栏。
- 点击资源后,中央主视窗从 `resource-overview.list` 切换为 `resource-overview.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器以路径、类型、来源任务、依赖层级、同类型上下游和版本字段为首屏主体,不使用页面级浮层或可拖动标题栏。文档正文与按意图加载的音频控制位于元数据之后。
- 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源编辑只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本。
- 资源聚焦不提供通用工具栏或工具侧边栏;图片聚焦态允许一个明确的“精修资源”业务动作进入素材创作无限画布,该动作不是在聚焦容器中内嵌编辑器或恢复通用工具栏。
- 点击资源后,中央主视窗从 `resource-overview.list` 切换为 `resource-overview.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器以路径、类型、来源任务、依赖层级、同类型上下游和版本字段为首屏主体,不使用页面级浮层或可拖动标题栏。文档正文与按意图加载的音频控制位于元数据之后。
- 焦点转换以稳定资源 ID 为准。只有从资源列表进入详情或从一个资源 ID 切换到另一个 ID 时聚焦详情 region;同一资源 ID 因 manifest 更新而重新投影时,不得抢走详情内音频 / 视频控件、文档链接或收起按钮的当前焦点。
- 显式收起或按 Escape 后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源,并优先把键盘焦点还给原触发资源卡;这些只属于当前前端会话,不写入布局 sidecar。若资源已经被后台删除,必须清理 stale focused / selected ID、关闭详情并把焦点落到“搜索项目资源”,不得落到 `body`。项目切换和进入运行视图必须取消旧项目的焦点恢复意图。
- 资源管理阶段四至阶段七交付上述受控读取、媒体展示、正式版本展示和引用高亮;`2026-08-05` 起图片编辑闭环生效,`2026-08-10` 起全类型非破坏性派生与版本子分支覆盖旧的只读限制。入口必须与草稿或 operation 恢复、正式事务提交、`referenceResourceIds` 血缘、新资源即时投影、两份布局和焦点竞态一次实现,不能只打开一个没有回写的面板。
@@ -328,8 +328,8 @@ type UpdateProjectResourceCanvasLayoutResult =
- 阶段五实现状态(2026-08-03):dependency 自动排列同时消费任务 DAG 与精确资源引用。Rust 把可信 producer 的任务深度作为资源深度下限,再对 `asset-reference` 图做迭代式 SCC 压缩与确定性层级传播;被引用资源位于引用资源之前,同一引用环共享稳定深度,环后资源继续递增,没有引用关系的资源保持默认不重叠位置。布局深度通过独立 `dependencyDepths` 返回,不能把 producer assignment 冒充全部资源的布局结果。
- `producerMappingTruncated=true` 只关闭依赖有界 Agent DB 审计的 `producerAssignments``taskFlows``cyclicTaskIds`。Rust 返回的 `dependencyDepths` 仍是 manifest / 精确引用 read model 的权威结果,前端必须过滤未知资源、负数、非整数和非安全整数后继续消费;不得因 producer 截断清空全部深度,也不得在前端重算替代深度。`referenceEdges`、connection index 中的 reference 关系、`cyclicResourceIds` 和 unresolved reference 继续保持可信。
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、逻辑 viewport 裁剪与原生 SVG path / marker。每个固定分区在自己的 `.game-resource-plane` 内持有一个 SVG,SVG 与卡片直接消费同一逻辑坐标、卡片尺寸、CSS scale 和 viewport scroll;端点主路径不得通过 `getBoundingClientRect` 拼接全局屏幕坐标。SVG 位于资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。
- `asset-reference` 表示精确资源引用;只有两端属于同一固定资源分类时才进入前端布局邻接、无障碍关系说明和 SVG,使用明亮橙色实线与实心箭头。分类引用仍可存在于 Rust read model,但资源管理画布不得绘线、不得以它形成布局簇或边界偏置。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
- 输入固定为当前资源投影的全部卡片身份 / 统一世界坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、世界 viewport 变换与原生 SVG path / marker。dependency 内容平面持有一个覆盖统一世界的 SVG,SVG 与卡片直接消费同一世界坐标和 viewport transform;端点主路径不得通过 `getBoundingClientRect` 拼接屏幕坐标。SVG 位于资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。
- `asset-reference` 表示精确资源引用;当前可见端点之间的同分类与跨分类引用都进入统一世界 SVG 和无障碍关系说明,使用明亮橙色实线与实心箭头。只有同分类引用参与自动布局邻接,跨分类引用不形成布局簇或边界偏置。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
- `task-flow` 表示同一资源类型内的任务产物流转,但资源画布不再绘制灰色虚线或任务流 marker,也不把它加入 `aria-describedby` 关系说明。它只按 `sourceTaskId -> targetTaskId + section` 作为聚合超边参与 dependency 连通簇和中位数排序,禁止对上下游资源生成笛卡尔积邻接;详情仍可展示 Rust read model 已证明的任务信息。
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate``assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifestExternal Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。
@@ -337,8 +337,8 @@ type UpdateProjectResourceCanvasLayoutResult =
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该橙线隐藏。task-flow 不产生显示几何,搜索也不因此重排布局。
- 同类型精确引用按稳定边 ID 和对端次序为同一卡片同侧的多条边分配独立端口;横向层级可用时优先左右连接,同列或横向间隙不足时才上下连接。同轴端点直接用直线,需转向时使用正交线段与最大 `10px` 的小圆角,不使用大范围贝塞尔控制柄。端口顺序不使用显示名称、随机数或浏览器枚举顺序,相同输入必须产生相同路径。资源点击只进入中央聚焦并保留当前选中卡片,不改变依赖卡片或连线的颜色、线宽与透明度;关系线始终直接展示,不提供点击后的上下游高亮或无关线弱化。
- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、项目切换或 section origin 变化而更新。
- 每个已挂载 dependency 分区最多构造一个 `ResizeObserver`,四区总数最多四个observer 只维护该分区的逻辑 viewport,不测量或重建卡片屏幕端点。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
- 分区高度、分区内部滚动和窗口 resize 在各分区内通过单一 `requestAnimationFrame` 调度器合并逻辑 viewport 更新,不得在每个 scroll event 中同步重算。浏览器以分区 viewport 原生裁剪同属该 plane 的 SVG 和卡片,因此线段不能穿过标题栏或泄漏到其它分区;一端可见时绘制入向 / 出向边界继续线,两端都离屏时不渲染。项目 / mode 切换或卸载时必须清理对应 observer、scroll 与 window resize 监听。
- 统一世界 dependency 平面最多构造一个 `ResizeObserver`observer 只维护世界 viewport,不测量或重建卡片屏幕端点。世界 extent 必须为最右侧自环和箭头保留视觉 gutter,但不得修改卡片坐标或布局 sidecar。
- 拖拽、滚轮平移、Ctrl/Meta + 滚轮缩放和窗口 resize 共用单一世界 viewport 变换;原生 `{ passive: false }` wheel 监听在 dependency 模式阻止 WebView 默认滚动或缩放,type 模式普通滚轮继续交给原生分区滚动。项目 / mode 切换或卸载时必须清理 observer、wheel 与 window resize 监听。
- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对与资源分区聚合,禁止为了布局分组生成资源笛卡尔积,且不进入 SVG。
### 5.3 资源类型与替换兼容性(P1)
@@ -498,9 +498,9 @@ type ProjectAgentMudPointAttribution = {
### 7.3 P1 资源依赖关系图验收
1. dependency 模式只显示同一资源类型分区内的精确引用,使用对画布背景至少 `3:1` 对比度的橙色实线箭头;聚合任务流只参与布局,不绘制灰色虚线或 marker。type 模式没有图层或连线。
1. dependency 模式在统一世界中显示当前可见端点之间的全部合法精确引用,包括跨分类引用,并使用对画布背景至少 `3:1` 对比度的橙色实线箭头;聚合任务流只参与同类型布局,不绘制灰色虚线或 marker。type 模式没有图层或连线。
2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。
3. 多资源任务依赖按资源类型分后只形成布局超边,以 `O(S+T)` 成员关系参与聚类,不产生 `S×T` 邻接、SVG 主线端点分支跨分区虚线
3. 多资源任务依赖按资源类型分后只形成布局超边,以 `O(S+T)` 成员关系参与聚类,不产生 `S×T` 邻接、SVG 主线端点分支跨分类精确引用只显示,不参与布局聚类
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
5. 搜索触发端点过滤;资源点击不改变上下游卡片或任何连线的视觉状态,资源卡指针移动不更新线段,点击与中央聚焦行为不回归。
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
@@ -508,7 +508,7 @@ type ProjectAgentMudPointAttribution = {
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。Agent DB 有界读取截断时 producer、task flow 与 `cyclicTaskIds` 失败关闭,精确 manifest 引用及 Rust 返回的合法 `dependencyDepths` 继续到达布局层。重新打开包含深度 `0 / 1 / 2` 自动坐标的旧布局时不得降成全 `0` 或持久化扁平布局;手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
9. 依赖 SVG 作为装饰层不可聚焦并对辅助技术隐藏;画布通过关联的视觉隐藏文本逐条说明当前可见的橙色精确引用,搜索过滤或模式切换后文本与可见关系同步变化。task-flow 不进入该关系说明。
10. 精确引用使用橙色实线和可见箭头;dependency 自动卡片之间保留横向 `48px`、纵向 `40px` 走线区,相关簇的窄层按本簇最大层高度居中。不同横向层级且卡片间有空间时使用左右端口优先的横线,同列、同深度或横向空间不足时才使用上下端口纵向降级。同行 / 同列直接连线,需要转向时只使用正交线段与最大 `10px` 小圆角。同一卡片同侧的多条精确引用按稳定边 ID 与对端位置分配不同端口;自引用继续在卡片右侧外绕,箭头端点与卡片保留固定显示间隙。
11. 每个分区 SVG 必须位于拥有相应资源卡的 `.game-resource-plane` 内,以同一逻辑坐标随原生滚动和 CSS scale 同步移动;滚动 / 缩放不得通过异步 DOM 屏幕测量重新绑定端点。分区 viewport 原生裁剪本区 SVG,不使用四区 viewport 并集裁剪;一端离屏时只显示对应方向、带同语义箭头的边界继续线,不保留指向已裁剪卡片的悬空主路径,两端离屏时隐藏。搜索隐藏精确引用任一端点时整边隐藏。
11. 统一世界 SVG 必须位于拥有全部 dependency 资源卡的内容平面内,以同一世界坐标随 viewport 平移和缩放同步移动;滚动 / 缩放不得通过异步 DOM 屏幕测量重新绑定端点。世界 viewport 裁剪 SVG 与卡片,搜索隐藏精确引用任一端点时整边及对应无障碍说明同时隐藏。
### 7.4 P1 正式项目版本阶段六验收
@@ -541,8 +541,8 @@ type ProjectAgentMudPointAttribution = {
实现状态(2026-08-15):当前产品切片禁用“新增资源”,只从现有资源进入非破坏性编辑。图片进入中央 refine 画布,其他现役类型进入统一派生编辑壳;取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。普通 Tauri 客户端登录陶泥儿后直接使用固定官方 origin 下的网站现役 `/api/editor/*``/api/assets/*``/api/runtime/external-generation/jobs/*`,不展示或要求填写 Base URL/API Key;短期 Access Token 只进入 WebView、GUI 与 Runner 内存。独立 standalone game-chat release/高级自定义模式保留隔离 AppData Developer API Key 与 `/api/external/v1/*`。重启恢复只继续原 operation,不以新请求、新幂等键或新 operationId 替代结果未知的旧任务。
1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core``@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;viewport、selection、变换、renderer 与 history 算法位于共享层,宿主只保留事件接线与 adapter 副作用。
2. “新增资源”在当前产品切片中保持禁用;“编辑资源”只接受现有资源。图片 refine 保留原资产与原文件、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘;其他类型同样只追加派生文件/asset 或子版本,不覆盖、删除或重排源记录
3. 草稿 schema、revision、容量、项目身份、OS 锁、CAS、恢复副本和媒体引用符合权威专题;损坏、未知 schema、身份错配和超限均失败关闭。应用重启后必须按 `projectId + intent + sourceAssetId + active status` 从正式 sidecar 唯一发现原 refine 草稿并保留 `draftId`、生成新 `sessionId`;零条才允许创建,多条必须进入对账,不得依赖进程内 Map 或按时间猜测。
2. “新增资源”在当前产品切片中保持禁用;“编辑资源”只接受现有资源。图片 refine 为稳定 `sourceAssetId` 保存一个持续精修草稿和多个私有 `draft-media` 候选;只有用户显式“设为最终图”时才安装新的正式 PNG,并保持原 asset ID 不变、事务化更新其 manifest `localPath/mediaType`。候选图不进入 manifest 或资源总览。其他类型继续只追加派生文件/asset 或子版本。
3. 草稿 schema、revision、容量、项目身份、OS 锁、CAS、恢复副本和媒体引用符合权威专题;损坏、未知 schema、身份错配和超限均失败关闭。应用重启后必须按 `projectId + intent + sourceAssetId + 可恢复/可继续状态` 从正式 sidecar 唯一发现原 refine 草稿并保留 `draftId`候选图和生成占位,同时生成新 `sessionId`;零条才允许创建,多条必须进入对账,不得依赖进程内 Map 或按时间猜测。
4. 正式提交携带 `expectedProjectId + expectedRevision + expectedDraftRevision + commitId + idempotencyKey`,按事务快照/journal/ledger、文件、manifest/revision、回读、ledger/draft、事件顺序完成;首个快照、全部快照、journal 写入、文件、manifest、revision、验证和 ledger 各崩溃阶段均有确定结果,未发布快照残留只在证明正式文件、manifest 与 revision 均未变化时清理。
5. 保存成功后不刷新、不重开项目即可进入 manifest 投影、依赖图、dependency/type 布局和允许时的选中定位;切项目、切中央状态、改选择或改搜索后的迟到结果不得抢焦点。
6. 搜索/筛选隐藏新资源时保留条件,明确提示“新资源已保存,当前筛选条件下不可见”,只通过显式动作清除条件并定位。
@@ -554,7 +554,7 @@ type ProjectAgentMudPointAttribution = {
12. `remote-failed` 已是远端明确终态,重启后不再 POST、不再轮询、不再扣费;`archived` 仅表示用户已将它移出活动恢复队列,不等于 `committed``result-unknown`、鉴权临时失败和 `reconciliation-required` 均不允许归档或重新生成。
13. 派生资产提交必须通过 durable asset transaction journal 串起最终文件、manifest 和 project revision。任一崩溃阶段恢复后只有一份派生文件、一条 manifest assetrevision 精确推进一次;manifest 已写而 revision 未写时只前向补 revision,无法证明的组合进入人工对账。journal 已证明目标 asset、媒体和 target revision 写入后,即使后续合法提交继续推进 manifest/revision,也应按目标 asset 精确身份与 `currentRevision >= targetRevision` 补齐 ledger/draft,不得要求整个 manifest 永远等于历史 after 快照。durable committed 后遗留的 staging 只有在 staging/正式媒体摘要一致,且 manifest 中按 asset ID 或路径唯一命中并与 journal asset 精确相等时才尽力删除;删除失败不降级已提交结果,身份或媒体漂移则保留 staging 并进入对账。
14. generation progress、草稿保存队列、生成/提交回包与延迟 `loadDraft` 必须共用单调 revision 门禁,低 revision 不得覆盖已落地的新草稿。Shift 指针与键盘选择必须与共享 core 一致;单选自身 Shift 不能清空选择,已选多图层普通指针拖动应保持并同步移动选择集。零位移不得产生 undo、documentVersion 或草稿保存。
15. 失败 UI 必须按 `generation / draft-save / asset-commit / recovery / cancellation` 五类 operation 显示可访问名称与安全动作。只有生成失败可以保留参数并“返回修改/重新确认”;草稿、CAS、提交、恢复和取消故障不得出现会发起新生成的按钮。任何非 `editing` 生命周期都必须同时禁用或 inert 背景画布/顶部工具栏,并由生成 handler 再次校验当前状态;modal 遮罩、视觉 disabled旧闭包不能充当业务门禁。
15. 失败 UI 必须按 `generation / draft-save / asset-commit / recovery / cancellation` 五类 operation 显示可访问名称与安全动作。生成是任务级状态:排队、运行、失败和对账不得用全屏遮罩终结画布;失败占位保留参数并允许重新修改。草稿、CAS、最终图提交、恢复和取消故障不得出现会发起新生成的按钮。真实写入和恢复阶段仍由 handler、revision 与锁负责门禁,视觉 disabled旧闭包或遮罩不能充当业务门禁。
16. 派生子版本 journal 必须冻结 project revision before/after 身份和目标 after 记录。manifest 已有目标子版本但 journal 缺失时失败关闭;旧 journal 缺少 revision 身份且当前 revision 已推进、无法证明是同一事务写入时进入人工对账,不得把现状猜测为已提交。
17. 文本、SVG 与 Agent 回执编辑必须在调用 Provider 前持久化 request-issuedProvider 成功正文必须在解析、格式校验和 staging 之前原子写入私有 durable handoff,并绑定原 operation、请求指纹和内容摘要。issued 后缺少可信 handoff 只能对账,已有可信 handoff 则只消费原正文,两者都不得再次调用 Provider。普通模式首次 `401` 只允许当前账号 generation 的单飞 refresh,成功后以更高 generation CAS 安装 Token,并使用原正文、原幂等键重试一次;refresh 失败、换号或再次 `401` 时保留原 operation,停止网络。`403` 不触发 refresh。高级 External v1 模式修正 Developer Key 后仍只查询或恢复原 operation。
@@ -569,10 +569,10 @@ type ProjectAgentMudPointAttribution = {
## 8. 非目标
- 当前不开放“新增资源”产品入口;现有资源只允许非破坏性编辑并追加新文件、asset 或子版本,源记录不覆盖、删除或重排
- 资源总览不实现资源卡手动拖动、通用聚焦工具栏/侧边栏、资源直接替换、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。
- 当前不开放“新增资源”产品入口。图片精修候选保持私有且不污染资源总览;“设为最终图”只允许保持原 asset ID、以新正式文件和事务方式切换 manifest 指针,不原地覆写旧文件。其他资源编辑继续追加派生 asset 或子版本
- 资源总览不实现资源卡手动拖动、通用聚焦工具栏/侧边栏、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因;资源详情仅为不影响背景画板的非模态卡片
- 素材创作不实现高级蒙版/毛发级抠图、图集、角色动画、视频时间线或音频波形级编辑;图片画布提供现役编辑闭环,其他类型使用统一非破坏性派生面板。
- 资源聚焦、搜索、筛选、dependency/type mode 与分区滚动/倍率均为当前会话态;素材创作 viewport 和图层状态使用独立草稿 schema,不能混用资源总览 sidecar。
- 资源聚焦、搜索、筛选、dependency/type mode 与分区滚动/倍率均为当前会话态;打开详情不得改变这些状态。素材创作 viewport、候选图层、generation 占位和最后最终图提交投影使用独立草稿 schema,不能混用资源总览 sidecar。
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
- 不自动确认 Agent 动作,不自动触发可能扣费的生成。
@@ -14271,3 +14271,31 @@
- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml canvas_generation_tests:: -- --test-threads=1`
- 2026-08-19 追加:同一默认认证切换也覆盖资源编辑和 autonomous main-loop fixture。`resource_editor` 的 External Editor 视频提交/轮询/服务身份恢复测试同样使用 task-local 凭据;平台账号语义测试使用隔离测试会话。main-loop 的视觉任务配置测试不再通过旧 `editorApi` 文件伪造登录态。所有 loopback listener 在 accept 时设有 5 秒 deadline,并把 accepted stream 恢复为 blocking,避免 Windows `WouldBlock(10035)` 或请求未发出时无限等待。
- 定向验证:`project::asset_canvas::generation::tests::` 14/14、`project::resource_editor::tests::` 36/36、`agent::runtime_driver::main_loop_tests::` 48/48、`agent::runtime_protocol::autonomous_completion_contract_tests::` 107/107 通过。此前一次 Windows 全量 Native Rust 为 1811 passed、108 failed、15 ignored;失败集合仍包含 Provider/mock 调度与既有专业链断言。HEAD 基线独立复现 `tests::project::generate_platform_art_asset_downloads_and_registers_external_image` 的同一登录态缺失,故不能把全量结果伪报为本次 fixture 修复引入;本次新增认证/accept deadline 相关用例均已隔离通过,最后两个 autonomous completion fixture 的认证迁移已单独通过,完整套件未在该两行测试改动后重新执行。
## 2026-08-20 AGC 图片精修候选与最终图事务
- 只有可栅格编辑的图片资源拥有持续精修草稿;同一 `sourceAssetId` 只恢复一个活动 refine 草稿。生成成功只把 PNG 写入草稿私有 `draft-media` 并追加候选图层,不自动修改 manifest,也不自动关闭画布。
- 每次生成在提交远端任务前冻结并持久化 `sourceLayerId` 与有边界的 `placeholder`。任务侧栏和画布占位共同消费 generation record;成功候选复用冻结落点,失败只终结当前任务,重启恢复不得重新提交或重复扣费。
- “设为最终图”是唯一正式资源切换入口:refine 保持原 asset ID,把正式文件切换到 `assets/canvas/<name>--<commitId>.png`,并用候选图层 ID 与媒体 SHA-256 交叉验证 staging、ledger 和 transaction journal。manifest 继续是当前最终图权威。
- 最终图事务完成或恢复后,草稿必须回到 `editing`,清空 `pendingCommit`,保留其它候选与 generation records,并在 `lastCommit.sourceLayerId / mediaSha256` 记录当前最终候选;不能把持续精修草稿永久停在 `committed`
- 资源详情保持非模态,不能卸载资源工具栏或背景画板。图片卡展示尺寸来自安全预览元数据,受 `220x180`、最小短边 `96``1:2..2:1` 约束;碰撞、世界范围和依赖连线共同消费同一实际矩形。
# 2026-08-21 Game Agent 精修来源、批量导入与失败任务归档
- refine 主来源不能仅凭已有 Editor Resource ID 直接复用;历史 Game Agent 私有 kind 必须基于本地正式图片重新登记为 External v1 canonical kind,并使用绑定服务身份、owner、Editor Project、源 SHA256 和 canonical kind 的本地私有缓存避免重复上传。不得扩大 External v1 快速编辑白名单或向 edit DTO 补发 `assetKind`
- Tauri 图片导入采用原生多选与 Rust 批量事务:安全读取、媒体安装、图层绑定和一次 draft revision 推进必须作为一个可恢复单元,前端只 hydrate 权威 draft,不再依赖隐藏 input 加后续 autosave 完成正式绑定。
- 生成失败任务的“删除”固定为归档私有 ledger 并移出 draft 公开投影;只允许明确 `failed`,结果未知和 `reconciliation-required` 必须继续留在恢复队列。任务侧栏折叠是会话 UI 状态,不进入业务持久化。
## 2026-08-21 Game Agent 图片生成恢复与正式图来源身份
- 图片 generation 恢复是任务级后台工作:关键提交事务恢复和草稿 hydrate 完成后,画布立即进入 editing;`recoverImages` 继续恢复原 operation 并更新任务投影,但不得锁住整张画布。
- 精修候选设为最终图保持入口资产 ID。入口 manifest 已有有效 `source.resourceId` 时原样保留;只有未登记来源才使用 `local-asset:<assetId>`。提交与恢复回读使用同一解析规则。
- Game Agent 任务列表视觉复用现役美术画布的右上角独立按钮、白色面板、活动/完成双 Tab 和状态图标;失败归档是 Game Agent 的业务扩展。阻断性画布错误通过 body portal 覆盖整个 Tauri WebView。
## 2026-08-21 Game Agent 资源自由画板使用稳定隐藏边界
- 依赖画板的导航范围由当前项目全部资源的权威世界 extent 决定,并在屏幕坐标外扩 96px 安全留白;搜索过滤、详情卡和临时可见性只影响展示,不缩小导航边界。
- 滚轮、指针平移、缩放、复位和容器 resize 统一经过 viewport constraint。最小缩放为全量资源边界 fit scale;内容小于视口时居中,内容较大时边缘不能越过安全留白。该边界是会话级导航约束,不持久化为第二份布局真相。

Some files were not shown because too many files have changed in this diff Show More