修复无限画布恢复与幂等链路
Project CI / Repository checks (pull_request) Successful in 1m0s
Project CI / Backend tests (pull_request) Successful in 3m48s
Project CI / Frontend tests (pull_request) Successful in 2m57s
Project CI / Native shell tests (pull_request) Failing after 11m11s

补齐 refine 草稿跨重启唯一恢复与生命周期门禁

完善 Provider durable handoff、认证恢复和旧密钥服务确认

修复资源事务前向恢复与异步导入迟到写入

补充前后端回归测试并同步权威文档与项目记忆
This commit is contained in:
2026-08-12 10:21:18 +08:00
parent 912df59c2b
commit daaef0917f
13 changed files with 2115 additions and 230 deletions
@@ -271,6 +271,14 @@ pub(crate) fn read_local_project_asset_canvas_draft(
read_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn discover_local_project_asset_canvas_draft(
input: DiscoverAssetCanvasDraftInput,
) -> Result<DiscoverAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
discover_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn update_local_project_asset_canvas_draft(
input: UpdateAssetCanvasDraftInput,
@@ -1271,6 +1279,24 @@ pub(crate) async fn resume_local_project_resource_edit(
resume_local_project_resource_edit_at(input).await
}
#[tauri::command]
pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation(
input: RequestResourceEditServiceIdentityConfirmationInput,
) -> Result<ResourceEditServiceIdentityConfirmation, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
request_resource_edit_service_identity_confirmation_at(input).await
}
#[tauri::command]
pub(crate) async fn confirm_local_project_resource_edit_service_identity(
input: ConfirmResourceEditServiceIdentityInput,
) -> Result<ConfirmResourceEditServiceIdentityResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
confirm_resource_edit_service_identity_at(input).await
}
#[tauri::command]
pub(crate) async fn archive_failed_local_project_resource_edit(
input: ArchiveFailedLocalProjectResourceEditInput,
@@ -2212,6 +2212,8 @@ fn main() {
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
request_local_project_resource_edit_service_identity_confirmation,
confirm_local_project_resource_edit_service_identity,
archive_failed_local_project_resource_edit,
normalize_local_project_raster_resource,
import_canvas_asset,
@@ -2263,6 +2265,7 @@ fn main() {
update_local_project_resource_canvas_layout,
create_local_project_asset_canvas_draft,
read_local_project_asset_canvas_draft,
discover_local_project_asset_canvas_draft,
update_local_project_asset_canvas_draft,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
@@ -23,6 +23,7 @@ const ASSET_CANVAS_MAX_DRAFT_MEDIA_BYTES: u64 = 512 * 1024 * 1024;
const ASSET_CANVAS_MAX_LAYERS: usize = 4096;
const ASSET_CANVAS_MAX_GENERATIONS: usize = 64;
const ASSET_CANVAS_MAX_REFERENCES: usize = 128;
const ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES: usize = 4096;
const ASSET_CANVAS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
const ASSET_CANVAS_MAX_DIMENSION: u32 = 16_384;
const ASSET_CANVAS_MAX_PIXELS: u64 = 268_435_456;
@@ -248,6 +249,29 @@ pub(crate) struct ReadAssetCanvasDraftResult {
pub(crate) draft: Option<AssetCanvasDraft>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct DiscoverAssetCanvasDraftInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) intent: AssetCanvasIntent,
pub(crate) source_asset_id: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum DiscoverAssetCanvasDraftStatus {
Found,
NotFound,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DiscoverAssetCanvasDraftResult {
pub(crate) status: DiscoverAssetCanvasDraftStatus,
pub(crate) draft: Option<AssetCanvasDraft>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct UpdateAssetCanvasDraftInput {
@@ -1231,6 +1255,94 @@ pub(crate) fn read_asset_canvas_draft_at(
})
}
pub(crate) fn discover_asset_canvas_draft_at(
root: &Path,
input: &DiscoverAssetCanvasDraftInput,
) -> Result<DiscoverAssetCanvasDraftResult, String> {
match (&input.intent, input.source_asset_id.as_deref()) {
(AssetCanvasIntent::Create, None) => {}
(AssetCanvasIntent::Refine, Some(source_asset_id)) => {
validate_plain_component(source_asset_id, "sourceAssetId", 512)?;
}
(AssetCanvasIntent::Create, Some(_)) => {
return Err("create 草稿发现不能指定 sourceAssetId".to_string());
}
(AssetCanvasIntent::Refine, None) => {
return Err("refine 草稿发现必须指定 sourceAssetId".to_string());
}
}
let preflight = validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
let _lock = acquire_asset_canvas_draft_lock(root)?;
let manifest = validate_asset_canvas_project_identity(root, &preflight.project_id)?;
if preflight.project_id != manifest.project_id {
return Err("项目身份在素材画布草稿发现期间发生变化".to_string());
}
let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/drafts"))?;
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(DiscoverAssetCanvasDraftResult {
status: DiscoverAssetCanvasDraftStatus::NotFound,
draft: None,
});
}
Err(error) => return Err(format!("读取素材画布草稿目录失败:{error}")),
};
let mut scanned_entries = 0_usize;
let mut matching = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| format!("读取素材画布草稿条目失败:{error}"))?;
scanned_entries += 1;
if scanned_entries > ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES {
return Err("素材画布草稿数量超过安全扫描上限".to_string());
}
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
if !entry
.file_type()
.map_err(|error| format!("读取素材画布草稿类型失败:{error}"))?
.is_file()
{
return Err("素材画布草稿条目必须是普通 JSON 文件".to_string());
}
let draft_id = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| "素材画布草稿文件名无效".to_string())?;
validate_uuid_v4(draft_id, "草稿文件名")?;
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)?
.ok_or_else(|| "素材画布草稿扫描结果不一致".to_string())?;
if draft.intent == input.intent
&& draft.source_asset_id == input.source_asset_id
&& matches!(
draft.status,
AssetCanvasDraftStatus::Editing
| AssetCanvasDraftStatus::Generating
| AssetCanvasDraftStatus::CommitPrepared
| AssetCanvasDraftStatus::ReconciliationRequired
)
{
matching.push(draft);
}
}
match matching.len() {
0 => Ok(DiscoverAssetCanvasDraftResult {
status: DiscoverAssetCanvasDraftStatus::NotFound,
draft: None,
}),
1 => Ok(DiscoverAssetCanvasDraftResult {
status: DiscoverAssetCanvasDraftStatus::Found,
draft: matching.pop(),
}),
_ => Err(
"reconciliation-required: 同一来源存在多个可恢复素材画布草稿,不能猜测恢复目标"
.to_string(),
),
}
}
pub(crate) fn update_asset_canvas_draft_at(
root: &Path,
input: &UpdateAssetCanvasDraftInput,
@@ -328,6 +328,73 @@ fn refine_preserves_source_and_records_non_destructive_lineage() {
.contains(&"local-asset:source-asset".to_string()));
}
#[test]
fn discovers_unique_active_refine_draft_and_rejects_ambiguous_candidates() {
let directory = tempfile::tempdir().expect("create refine discovery fixture");
let root = directory.path();
init_local_game_project_at(root, PROJECT_ID, "精修草稿发现测试项目")
.expect("initialize project");
let source_bytes = png_bytes([80, 120, 160, 255]);
fs::write(root.join("assets/source.png"), source_bytes).expect("write source asset");
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = read_manifest(&manifest_path).expect("read manifest");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "source-asset".to_string(),
kind: "illustration".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/source.png".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
});
write_manifest(&manifest_path, &manifest).expect("register source asset");
let discovery = DiscoverAssetCanvasDraftInput {
project_path: project_path(root),
expected_project_id: PROJECT_ID.to_string(),
intent: AssetCanvasIntent::Refine,
source_asset_id: Some("source-asset".to_string()),
};
let first_id = Uuid::new_v4().to_string();
create_asset_canvas_draft_at(
root,
&create_input(
root,
&first_id,
AssetCanvasIntent::Refine,
Some("source-asset".to_string()),
),
)
.expect("create first refine draft");
let found = discover_asset_canvas_draft_at(root, &discovery).expect("discover refine draft");
assert_eq!(found.status, DiscoverAssetCanvasDraftStatus::Found);
assert_eq!(found.draft.expect("found draft").draft_id, first_id);
let second_id = Uuid::new_v4().to_string();
create_asset_canvas_draft_at(
root,
&create_input(
root,
&second_id,
AssetCanvasIntent::Refine,
Some("source-asset".to_string()),
),
)
.expect("create second refine draft");
let error = discover_asset_canvas_draft_at(root, &discovery)
.expect_err("ambiguous refine drafts must fail closed");
assert!(error.contains("多个可恢复素材画布草稿"));
}
#[test]
fn draft_cas_continuous_saves_and_trusted_restart_recovery() {
let fixture = initialize_fixture();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -98,6 +98,16 @@ type ResourceSortMode = ProjectResourceCanvasLayoutMode;
type WorkbenchMode = 'resources' | 'run';
type ApprovalMode = 'strict' | 'risk' | 'none';
type DiscoverAssetCanvasDraftResult = {
status: 'found' | 'not-found';
draft: {
draftId: string;
projectId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
} | null;
};
type AssetCanvasRoute = {
flowId: string;
sessionId: string;
@@ -136,6 +146,15 @@ type PendingLocalProjectResourceEdit = {
type PendingResourceEditsLoadState = 'idle' | 'loading' | 'ready' | 'failed';
type ResourceEditServiceIdentityConfirmation = {
operationId: string;
remoteOperationId: string | null;
operationState: string;
serviceOrigin: string;
challenge: string;
expiresAt: number;
};
function pendingResourceEditKindLabel(editKind: string) {
if (editKind === 'image') return '图片编辑';
if (editKind === 'text') return '文本编辑';
@@ -549,6 +568,12 @@ export default function ProjectDevelopmentView({
useState<Set<string>>(() => new Set());
const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] =
useState<Map<string, string>>(() => new Map());
const [
resourceEditServiceIdentityConfirmations,
setResourceEditServiceIdentityConfirmations,
] = useState<Map<string, ResourceEditServiceIdentityConfirmation>>(
() => new Map(),
);
const [hiddenCommittedResourceId, setHiddenCommittedResourceId] = useState<
string | null
>(null);
@@ -586,7 +611,6 @@ export default function ProjectDevelopmentView({
const focusedCommitIdsRef = useRef(new Set<string>());
const activeFocusFlowIdRef = useRef<string | null>(null);
const assetCanvasRouteRef = useRef<AssetCanvasRoute | null>(null);
const resumableRefineDraftsRef = useRef(new Map<string, string>());
const resourceEditorRevisionRef = useRef(new Map<string, number>());
const pendingResourceEditsReadEpochRef = useRef(0);
const pendingResourceEditActionIdsRef = useRef(new Set<string>());
@@ -1057,6 +1081,7 @@ export default function ProjectDevelopmentView({
setPendingResourceEditsLoadState('idle');
setPendingResourceEditsError('');
setPendingResourceEditActionErrors(new Map());
setResourceEditServiceIdentityConfirmations(new Map());
resourceRecoveryPanelEpochRef.current += 1;
setResourceRecoveryPanelOpen(false);
pendingResourceEditActionIdsRef.current = new Set();
@@ -1472,7 +1497,7 @@ export default function ProjectDevelopmentView({
);
const beginAssetCanvas = useCallback(
(
async (
sourceAsset: GameCreationAppAssetManifestEntry,
expectedProjectRevision: number,
) => {
@@ -1484,13 +1509,55 @@ export default function ProjectDevelopmentView({
advanceFocusGeneration();
pendingResourceFocusRef.current = null;
setHiddenCommittedResourceId(null);
const openEpoch = canvasOpenEpochRef.current + 1;
canvasOpenEpochRef.current = openEpoch;
const flowId = crypto.randomUUID();
const sessionId = crypto.randomUUID();
let discovered: DiscoverAssetCanvasDraftResult;
try {
discovered = await invoke<DiscoverAssetCanvasDraftResult>(
'discover_local_project_asset_canvas_draft',
{
input: {
projectPath,
expectedProjectId: manifest.projectId,
intent: 'refine',
sourceAssetId: sourceAsset.id,
},
},
);
} catch (error) {
if (canvasOpenEpochRef.current === openEpoch) {
setAssetCanvasNotice(
error instanceof Error ? error.message : String(error),
);
}
return;
}
const currentProject = currentProjectIdentityRef.current;
if (
canvasOpenEpochRef.current !== openEpoch ||
currentProject.projectPath !== projectPath ||
currentProject.projectId !== manifest.projectId
) {
return;
}
const draftId =
discovered.status === 'found' &&
discovered.draft?.projectId === manifest.projectId &&
discovered.draft.intent === 'refine' &&
discovered.draft.sourceAssetId === sourceAsset.id
? discovered.draft.draftId
: discovered.status === 'not-found' && discovered.draft === null
? crypto.randomUUID()
: null;
if (!draftId) {
setAssetCanvasNotice('可恢复素材草稿与当前资源身份不一致');
return;
}
const scope: ImageCanvasHostScope = {
projectId: manifest.projectId,
draftId:
resumableRefineDraftsRef.current.get(sourceAsset.id) ??
crypto.randomUUID(),
draftId,
intent: 'refine',
sourceAssetId: sourceAsset.id,
};
@@ -1547,7 +1614,7 @@ export default function ProjectDevelopmentView({
if (!Number.isSafeInteger(status.revision) || status.revision < 0) {
throw new Error('项目 revision 无效');
}
beginAssetCanvas(sourceAsset, status.revision);
void beginAssetCanvas(sourceAsset, status.revision);
})
.catch((error: unknown) => {
if (canvasOpenEpochRef.current !== openEpoch) {
@@ -1620,7 +1687,10 @@ export default function ProjectDevelopmentView({
source: 'asset-command',
commitId: `normalize:${normalized.asset.id}`,
});
beginAssetCanvas(normalized.asset, normalized.committedProjectRevision);
void beginAssetCanvas(
normalized.asset,
normalized.committedProjectRevision,
);
} catch (error) {
if (canvasOpenEpochRef.current === openEpoch) {
setAssetCanvasNotice(
@@ -1781,9 +1851,47 @@ export default function ProjectDevelopmentView({
} catch (error) {
if (!isCurrentRecoveryProject(actionProject)) return;
const message = error instanceof Error ? error.message : String(error);
let displayMessage = message;
if (message.includes('service-identity-confirmation-required')) {
try {
const confirmation =
await invoke<ResourceEditServiceIdentityConfirmation>(
'request_local_project_resource_edit_service_identity_confirmation',
{
input: {
projectPath: actionProject.projectPath,
expectedProjectId: actionProject.projectId,
operationId: pending.operationId,
},
},
);
if (
!isCurrentRecoveryProject(actionProject) ||
confirmation.operationId !== pending.operationId ||
!confirmation.serviceOrigin ||
!confirmation.challenge
) {
throw new Error(
'资源编辑服务身份确认结果与当前 operation 不一致',
);
}
setResourceEditServiceIdentityConfirmations((current) => {
const next = new Map(current);
next.set(pending.operationId, confirmation);
return next;
});
displayMessage = `请确认“${confirmation.serviceOrigin}”仍是该 operation 的原服务后再继续`;
} catch (confirmationError) {
if (!isCurrentRecoveryProject(actionProject)) return;
displayMessage =
confirmationError instanceof Error
? confirmationError.message
: String(confirmationError);
}
}
setPendingResourceEditActionErrors((current) => {
const next = new Map(current);
next.set(pending.operationId, message);
next.set(pending.operationId, displayMessage);
return next;
});
await refreshPendingResourceEdits();
@@ -1809,6 +1917,83 @@ export default function ProjectDevelopmentView({
],
);
const confirmPendingResourceEditServiceIdentity = useCallback(
async (pending: PendingLocalProjectResourceEdit) => {
const confirmation = resourceEditServiceIdentityConfirmations.get(
pending.operationId,
);
const invoke = window.__TAURI__?.core?.invoke;
const actionProject = {
projectPath,
projectId: manifest.projectId,
};
if (
!confirmation ||
!invoke ||
!isCurrentRecoveryProject(actionProject) ||
!beginPendingResourceEditAction(pending.operationId)
) {
return;
}
let shouldResume = false;
setPendingResourceEditActionErrors((current) => {
if (!current.has(pending.operationId)) return current;
const next = new Map(current);
next.delete(pending.operationId);
return next;
});
try {
await invoke('confirm_local_project_resource_edit_service_identity', {
input: {
projectPath: actionProject.projectPath,
expectedProjectId: actionProject.projectId,
operationId: pending.operationId,
remoteOperationId: confirmation.remoteOperationId,
challenge: confirmation.challenge,
},
});
if (!isCurrentRecoveryProject(actionProject)) return;
setResourceEditServiceIdentityConfirmations((current) => {
if (!current.has(pending.operationId)) return current;
const next = new Map(current);
next.delete(pending.operationId);
return next;
});
shouldResume = true;
} catch (error) {
if (!isCurrentRecoveryProject(actionProject)) return;
const message = error instanceof Error ? error.message : String(error);
setResourceEditServiceIdentityConfirmations((current) => {
if (!current.has(pending.operationId)) return current;
const next = new Map(current);
next.delete(pending.operationId);
return next;
});
setPendingResourceEditActionErrors((current) => {
const next = new Map(current);
next.set(pending.operationId, message);
return next;
});
} finally {
if (isCurrentRecoveryProject(actionProject)) {
finishPendingResourceEditAction(pending.operationId);
}
}
if (shouldResume && isCurrentRecoveryProject(actionProject)) {
void resumePendingResourceEdit(pending);
}
},
[
beginPendingResourceEditAction,
finishPendingResourceEditAction,
isCurrentRecoveryProject,
manifest.projectId,
projectPath,
resourceEditServiceIdentityConfirmations,
resumePendingResourceEdit,
],
);
const archiveFailedPendingResourceEdit = useCallback(
async (pending: PendingLocalProjectResourceEdit) => {
const invoke = window.__TAURI__?.core?.invoke;
@@ -1979,16 +2164,7 @@ export default function ProjectDevelopmentView({
);
const cancelAssetCanvas = useCallback(
(result: AssetCanvasExitResult) => {
const route = assetCanvasRouteRef.current;
const sourceAssetId = route?.scope.sourceAssetId;
if (sourceAssetId) {
if (result.disposition === 'kept') {
resumableRefineDraftsRef.current.set(sourceAssetId, result.draftId);
} else {
resumableRefineDraftsRef.current.delete(sourceAssetId);
}
}
(_result: AssetCanvasExitResult) => {
canvasOpenEpochRef.current += 1;
advanceFocusGeneration();
activeFocusFlowIdRef.current = null;
@@ -2061,9 +2237,6 @@ export default function ProjectDevelopmentView({
...focusIntent,
resourceId: `asset:${notification.assetId}`,
};
if (route.scope.sourceAssetId) {
resumableRefineDraftsRef.current.delete(route.scope.sourceAssetId);
}
setAssetCanvasRoute(null);
setMode('resources');
setFocusedResourceId(null);
@@ -3038,6 +3211,10 @@ export default function ProjectDevelopmentView({
const actionPending = pendingResourceEditActionIds.has(
pending.operationId,
);
const identityConfirmation =
resourceEditServiceIdentityConfirmations.get(
pending.operationId,
);
return (
<article
className="game-resource-recovery-item"
@@ -3053,7 +3230,24 @@ export default function ProjectDevelopmentView({
{pendingResourceEditPhaseLabel(pending.phase)}
</span>
</div>
{pending.phase === 'remote-failed' ? (
{identityConfirmation ? (
<div className="game-resource-recovery-confirmation">
<span>
{identityConfirmation.serviceOrigin}
</span>
<button
type="button"
onClick={() =>
void confirmPendingResourceEditServiceIdentity(
pending,
)
}
disabled={actionPending}
>
{actionPending ? '正在确认…' : '确认当前服务并继续'}
</button>
</div>
) : pending.phase === 'remote-failed' ? (
<button
type="button"
onClick={() =>
@@ -161,6 +161,8 @@ function deferred<T>(): Deferred<T> {
function memoryHost(input?: {
initialDraft?: ImageCanvasDraft | null;
recoverGate?: Deferred<unknown>;
recoverImagesGate?: Deferred<unknown>;
importGate?: Deferred<unknown>;
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
generationFailure?: { code: string; message: string };
@@ -233,6 +235,7 @@ function memoryHost(input?: {
recoverInput: Parameters<ImageCanvasGenerationPort['recoverImages']>[0],
) => {
recoveryProgressListener = recoverInput.onProgress;
await input?.recoverImagesGate?.promise;
return {
status: 'ok' as const,
value: {
@@ -318,6 +321,7 @@ function memoryHost(input?: {
},
asset: {
async importImages(importInput) {
await input?.importGate?.promise;
return {
status: 'ok',
value: importInput.images.map((image, index) => ({
@@ -529,6 +533,51 @@ function renderSurface(
};
}
function expectAssetCanvasBackgroundLocked() {
const toolbarShell = document.querySelector(
'.asset-canvas-surface__toolbar-shell',
);
const viewport = document.querySelector('.asset-canvas-surface__viewport');
const generate = screen.getByRole('button', {
name: 'AI 生成图片',
hidden: true,
}) as HTMLButtonElement;
const commit = screen.getByRole('button', {
name: '保存到项目',
hidden: true,
}) as HTMLButtonElement;
expect(toolbarShell?.hasAttribute('inert')).toBe(true);
expect(viewport?.hasAttribute('inert')).toBe(true);
expect(generate.disabled).toBe(true);
expect(commit.disabled).toBe(true);
}
function expectLockedBackgroundCannotCallPaidPorts(
memory: ReturnType<typeof memoryHost>,
) {
const generationCallCount = memory.generationCalls.length;
const commitCallCount = memory.commits.length;
const generate = screen.getByRole('button', {
name: 'AI 生成图片',
hidden: true,
}) as HTMLButtonElement;
const commit = screen.getByRole('button', {
name: '保存到项目',
hidden: true,
}) as HTMLButtonElement;
// 模拟宿主或测试绕过 disabled,确认 handler 自身仍有生命周期硬门禁。
generate.disabled = false;
commit.disabled = false;
fireEvent.click(generate);
fireEvent.click(commit);
generate.disabled = true;
commit.disabled = true;
expect(memory.generationCalls).toHaveLength(generationCallCount);
expect(memory.commits).toHaveLength(commitCallCount);
}
beforeEach(() => {
clearStoredAuthAccessToken();
Object.defineProperty(window, 'PointerEvent', {
@@ -596,6 +645,82 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy();
});
it('任一独立 modal 打开时都隔离背景焦点并阻断生成与提交端口', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
});
const view = renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '关闭图片生成面板' }),
),
);
fireEvent.click(screen.getByRole('button', { name: '关闭图片生成面板' }));
fireEvent.click(screen.getByRole('button', { name: '选择图层 第一层' }));
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '继续编辑' }),
),
);
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
view.unmount();
const identityMemory = memoryHost({
initialDraft: draftFixture(scope),
serviceIdentityConfirmation: {
generationId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
operationId: 'operation-one',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-one-that-is-long-enough-for-the-contract',
expiresAt: Date.now() + 60_000,
},
});
renderSurface(identityMemory.host);
expect(
await screen.findByRole('dialog', { name: '确认旧生成任务服务' }),
).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(identityMemory);
await waitFor(() =>
expect(document.activeElement).toBe(
screen.getByRole('button', { name: '暂不恢复旧任务' }),
),
);
});
it('原 generation recovery 完成前保持 recovering 并锁闭付费端口', async () => {
const recoverImagesGate = deferred<unknown>();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
recoverImagesGate,
});
renderSurface(memory.host);
await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(1));
expect(
screen.getByRole('region', { name: '素材创作无限画布' }).dataset.state,
).toBe('canvas.recovering');
expect(screen.queryByRole('button', { name: 'AI 生成图片' })).toBeNull();
expect(memory.generationCalls).toHaveLength(0);
expect(memory.commits).toHaveLength(0);
expect(screen.queryByText('画布可编辑')).toBeNull();
recoverImagesGate.resolve(undefined);
expect(await screen.findByText('画布可编辑')).not.toBeNull();
});
it('保留可编辑名称,并把新建资源用途限制为中文固定选项', async () => {
const memory = memoryHost();
renderSurface(memory.host);
@@ -762,6 +887,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
).toContain('测试草稿保存失败');
expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
fireEvent.click(screen.getByRole('button', { name: '返回资源总览' }));
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
@@ -789,6 +916,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
).not.toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expect(memory.generationCalls).toHaveLength(0);
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
});
it('恢复与取消故障使用各自的可访问名称和允许动作', async () => {
@@ -804,6 +933,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
await screen.findByRole('alert', { name: '原任务恢复未完成' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(recoveryMemory);
fireEvent.click(screen.getByRole('button', { name: '返回资源总览' }));
expect(recoveryView.onCancel).toHaveBeenCalledWith({
draftId: scope.draftId,
@@ -832,6 +963,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
screen.getByRole('button', { name: '保留草稿继续编辑' }),
).not.toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(cancellationMemory);
});
it('只有明确放弃草稿时才写入 cancelled', async () => {
@@ -1429,6 +1562,34 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByText(/已保存到项目 assets/)).toBeTruthy();
});
it('导入回包到达时 modal 已打开则不追加未计 revision 的图层', async () => {
const importGate = deferred<unknown>();
const memory = memoryHost({ importGate });
renderSurface(memory.host);
expect(await screen.findByText('画布可编辑')).toBeTruthy();
const file = new File([Uint8Array.from([1, 2, 3])], 'late.png', {
type: 'image/png',
});
Object.defineProperty(file, 'arrayBuffer', {
configurable: true,
value: async () => Uint8Array.from([1, 2, 3]).buffer,
});
fireEvent.change(screen.getByLabelText('导入本地图片'), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).not.toBeNull();
await act(async () => {
importGate.resolve(undefined);
await importGate.promise;
});
await waitFor(() =>
expect(screen.queryByLabelText('图层 late.png')).toBeNull(),
);
expect(memory.updates).toHaveLength(0);
});
it('保存连点保持 single-flight,命令事件重放不重复投影', async () => {
const commitGate = deferred<void>();
const canvas = emptyCanvas();
@@ -1744,6 +1905,8 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(failure.textContent).toContain('泥点余额不足');
expect(screen.getByRole('button', { name: '返回修改' })).toBeTruthy();
expect(screen.getByRole('button', { name: '重新确认' })).toBeTruthy();
expectAssetCanvasBackgroundLocked();
expectLockedBackgroundCannotCallPaidPorts(memory);
fireEvent.click(screen.getByRole('button', { name: '返回修改' }));
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
@@ -8,6 +8,7 @@ const canvasFixture = vi.hoisted(() => ({
manifest: null as GameCreationAppManifest | null,
revision: 0,
sequence: 0,
recoveredDraftIds: [] as string[],
}));
vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
@@ -35,8 +36,23 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
commitId: string;
}) => void;
onCommitted?: (notification: Record<string, unknown>) => void;
}) =>
ReactModule.createElement(
host: {
generation: {
recoverImages: (input: {
scope: {
projectId: string;
draftId: string;
intent: 'create' | 'refine';
sourceAssetId: string | null;
};
}) => Promise<unknown>;
};
};
}) => {
ReactModule.useEffect(() => {
void props.host.generation.recoverImages({ scope: props.scope });
}, [props.host, props.scope]);
return ReactModule.createElement(
'section',
{ 'aria-label': '测试素材创作画布' },
ReactModule.createElement(
@@ -114,7 +130,8 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
},
'完成测试保存',
),
),
);
},
};
});
@@ -354,6 +371,7 @@ describe('project resource live canvas integration', () => {
canvasFixture.manifest = null;
canvasFixture.revision = 0;
canvasFixture.sequence = 0;
canvasFixture.recoveredDraftIds = [];
});
function installTauri(
@@ -369,6 +387,12 @@ describe('project resource live canvas integration', () => {
resumeResourceEdit?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
requestResourceEditServiceIdentityConfirmation?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
confirmResourceEditServiceIdentity?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
} = {},
) {
const layoutWrites: Array<Record<string, unknown>> = [];
@@ -376,9 +400,12 @@ describe('project resource live canvas integration', () => {
const deriveCalls: Array<Record<string, unknown>> = [];
const normalizeCalls: Array<Record<string, unknown>> = [];
const resumeCalls: Array<Record<string, unknown>> = [];
const serviceIdentityRequestCalls: Array<Record<string, unknown>> = [];
const serviceIdentityConfirmCalls: Array<Record<string, unknown>> = [];
const archiveCalls: Array<Record<string, unknown>> = [];
const pendingReadCalls: Array<Record<string, unknown>> = [];
let pendingReadCount = 0;
let discoverableDraftId: string | null = null;
let pendingEdits =
options.pendingResourceEdits ??
(options.pendingResourceEdit
@@ -395,6 +422,20 @@ describe('project resource live canvas integration', () => {
: []);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'discover_local_project_asset_canvas_draft') {
const input = (args?.input ?? {}) as Record<string, unknown>;
return discoverableDraftId
? {
status: 'found',
draft: {
draftId: discoverableDraftId,
projectId: String(input.expectedProjectId),
intent: 'refine',
sourceAssetId: String(input.sourceAssetId),
},
}
: { status: 'not-found', draft: null };
}
if (command === 'get_local_game_project_revision') {
return { revision: canvasFixture.revision };
}
@@ -437,6 +478,17 @@ describe('project resource live canvas integration', () => {
dataUrl: 'data:image/png;base64,AA==',
};
}
if (command === 'recover_local_project_asset_canvas_generations') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
discoverableDraftId = String(input.draftId);
canvasFixture.recoveredDraftIds.push(String(input.draftId));
return {
resumedGenerationIds: [`generation-for-${String(input.draftId)}`],
serviceIdentityConfirmations: [],
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
@@ -499,6 +551,33 @@ describe('project resource live canvas integration', () => {
manifest: nextManifest,
};
}
if (
command ===
'request_local_project_resource_edit_service_identity_confirmation'
) {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
serviceIdentityRequestCalls.push(input);
if (options.requestResourceEditServiceIdentityConfirmation) {
return await options.requestResourceEditServiceIdentityConfirmation(
input,
);
}
throw new Error('unexpected resource edit service identity request');
}
if (
command === 'confirm_local_project_resource_edit_service_identity'
) {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
serviceIdentityConfirmCalls.push(input);
if (options.confirmResourceEditServiceIdentity) {
return await options.confirmResourceEditServiceIdentity(input);
}
throw new Error('unexpected resource edit service identity confirm');
}
if (command === 'archive_failed_local_project_resource_edit') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
@@ -593,6 +672,8 @@ describe('project resource live canvas integration', () => {
pendingReadCalls,
resumeCalls,
archiveCalls,
serviceIdentityRequestCalls,
serviceIdentityConfirmCalls,
};
}
@@ -668,6 +749,35 @@ describe('project resource live canvas integration', () => {
).toBe(true);
});
it('reuses a kept refine draft and recovers its generation after a real workbench remount', async () => {
installTauri();
const firstWorkbench = render(<LiveWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
await waitFor(() => {
expect(canvasFixture.recoveredDraftIds).toHaveLength(1);
});
const keptDraftId = canvasFixture.recoveredDraftIds[0];
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
firstWorkbench.unmount();
render(<LiveWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /source-art\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
await waitFor(() => {
expect(canvasFixture.recoveredDraftIds).toEqual([
keptDraftId,
keptDraftId,
]);
});
});
it('keeps an existing search when an edited result is hidden and locates only after the explicit action', async () => {
installTauri();
render(<LiveWorkbench />);
@@ -773,6 +883,102 @@ describe('project resource live canvas integration', () => {
).not.toBeNull();
});
it('显式确认旧 Key-bound 服务后只用原 operation 继续恢复', async () => {
const operation: PendingResourceEditFixture = {
operationId: 'abababab-abab-4bab-8bab-abababababab',
editKind: 'video',
sourceResourceId: 'source-video',
assetName: '旧 Key 视频编辑',
phase: 'accepted',
createdAt: 1,
};
let identityConfirmed = false;
let committed = false;
const {
resumeCalls,
serviceIdentityRequestCalls,
serviceIdentityConfirmCalls,
} = installTauri({
readPendingResourceEdits: () => (committed ? [] : [operation]),
resumeResourceEdit: async () => {
if (!identityConfirmed) {
throw new Error(
'service-identity-confirmation-required: 当前服务地址需要用户确认',
);
}
const base = canvasFixture.manifest!;
const asset = {
id: `edit-${operation.operationId}`,
kind: 'video',
mediaType: 'video/mp4',
localPath: `assets/edits/${operation.operationId}.mp4`,
source: {
kind: 'canvas' as const,
resourceId: 'remote-video-resource',
referenceResourceIds: ['source-video'],
},
};
committed = true;
canvasFixture.revision += 1;
const manifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = manifest;
return {
operationId: operation.operationId,
editKind: 'video',
sourceResourceId: 'source-video',
committedProjectRevision: canvasFixture.revision,
asset,
version: null,
manifest,
};
},
requestResourceEditServiceIdentityConfirmation: async (input) => ({
operationId: input.operationId,
remoteOperationId: 'remote-existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
challenge: 'challenge-bound-to-the-current-ledger-snapshot',
expiresAt: 9_999_999_999,
}),
confirmResourceEditServiceIdentity: async (input) => {
expect(input).toMatchObject({
operationId: operation.operationId,
remoteOperationId: 'remote-existing-operation',
challenge: 'challenge-bound-to-the-current-ledger-snapshot',
});
identityConfirmed = true;
return {
operationId: operation.operationId,
remoteOperationId: 'remote-existing-operation',
operationState: 'accepted',
serviceOrigin: 'https://editor.example.test',
identityScheme: 'service-origin-v1',
};
},
});
render(<DerivedWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: '管理未完成编辑 (1)' }),
);
fireEvent.click(screen.getByRole('button', { name: '继续原编辑' }));
expect(
await screen.findByText('当前服务:https://editor.example.test'),
).not.toBeNull();
expect(serviceIdentityRequestCalls).toHaveLength(1);
expect(resumeCalls).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '确认当前服务并继续' }));
await waitFor(() => expect(resumeCalls).toHaveLength(2));
expect(serviceIdentityConfirmCalls).toHaveLength(1);
expect(resumeCalls[0]).toEqual(resumeCalls[1]);
expect(resumeCalls[1]).toEqual({
projectPath,
expectedProjectId: 'live-canvas-project',
operationId: operation.operationId,
});
});
it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => {
const failedOperationId = '11111111-1111-4111-8111-111111111111';
const resumableOperationId = '22222222-2222-4222-8222-222222222222';
@@ -502,20 +502,21 @@ type ProjectAgentMudPointAttribution = {
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、身份错配和超限均失败关闭。
3. 草稿 schema、revision、容量、项目身份、OS 锁、CAS、恢复副本和媒体引用符合权威专题;损坏、未知 schema、身份错配和超限均失败关闭。应用重启后必须按 `projectId + intent + sourceAssetId + active status` 从正式 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. 搜索/筛选隐藏新资源时保留条件,明确提示“新资源已保存,当前筛选条件下不可见”,只通过显式动作清除条件并定位。
7. 现有资源编辑、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 中与当前非破坏性编辑切片对应的验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。
8. 自动定位必须分别证明资源已投影、dependency/type 两份布局都 settled 且存在目标位置、目标卡 DOM 已提交;搜索隐藏走显式清除/定位,任何 commit 最多自动聚焦一次。
9. Tauri 远端媒体编辑只使用发布 AppData 私有 External v1 配置;后端按 owner 预扣/退款泥点并返回可轮询 operation。凭据失效、余额不足、平台生成配置故障和远端失败必须在画布内可见;生成状态不得因固定高度或 `overflow` 裁剪而消失,终态后刷新钱包余额。
10. 素材画布生成账本的服务身份固定为 `service-origin-v1`,绑定规范化 External base URL 的服务指纹,不绑定 Developer API Key;确认面板只展示去除路径与凭据的服务 origin。无法用当前 Key 验证的升级前 Key-bound 账本在任何网络动作前显示当前服务 origin 并要求用户确认;确认挑战过期、账本变化或 base URL 变化均失败关闭。确认后 `accepted/running` 只恢复原 GET`prepared` 只可精确重放冻结的原 POST。全类型资源编辑复用同一身份分类,旧 Key-bound 指纹无法验证时保留原 operation 进入对账,不自动接管或重放
10. 素材画布生成账本的服务身份固定为 `service-origin-v1`,绑定规范化 External base URL 的服务指纹,不绑定 Developer API Key;确认面板只展示去除路径与凭据的服务 origin。无法用当前 Key 验证的升级前 Key-bound 账本在任何网络动作前显示当前服务 origin 并要求用户确认;确认挑战过期、账本变化或 base URL 变化均失败关闭。确认后 `accepted/running` 只恢复原 GET`prepared` 只可精确重放冻结的原 POST。全类型资源编辑复用相同显式确认迁移:旧 Key-bound 指纹无法验证时确认前保持零网络动作,确认后已存在 `operationId` 的任务只 GET 原 operation,尚未受理的冻结请求才可复用原幂等键精确 POST
11. 资源编辑恢复面板必须为独立 modal,展示后端权威队列的全部 operation。用户可继续任意可恢复项;`remote-failed` 只允许显式移出活动队列,并保留私有账本审计;`reconciliation-required` 只读展示对账。读取失败必须提供重试,不得伪装空队列;操作后必须重读后端。
12. `remote-failed` 已是远端明确终态,重启后不再 POST、不再轮询、不再扣费;`archived` 仅表示用户已将它移出活动恢复队列,不等于 `committed``result-unknown`、鉴权临时失败和 `reconciliation-required` 均不允许归档或重新生成。
13. 派生资产提交必须通过 durable asset transaction journal 串起最终文件、manifest 和 project revision。任一崩溃阶段恢复后只有一份派生文件、一条 manifest assetrevision 精确推进一次;manifest 已写而 revision 未写时只前向补 revision,无法证明的组合进入人工对账。durable committed 后遗留的 staging 只有在 staging/正式媒体摘要一致,且 manifest 中按 asset ID 或路径唯一命中并与 journal asset 精确相等时才尽力删除;删除失败不降级已提交结果,身份或媒体漂移则保留 staging 并进入对账。
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、提交、恢复和取消故障不得出现会发起新生成的按钮。
15. 失败 UI 必须按 `generation / draft-save / asset-commit / recovery / cancellation` 五类 operation 显示可访问名称与安全动作。只有生成失败可以保留参数并“返回修改/重新确认”;草稿、CAS、提交、恢复和取消故障不得出现会发起新生成的按钮。任何非 `editing` 生命周期都必须同时禁用或 inert 背景画布/顶部工具栏,并由生成 handler 再次校验当前状态;modal 遮罩、视觉 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/403 只表示当前凭据不能继续授权,不能把已受理 operation 改写为永久对账;修正 Key 后仍只查询原 operation。
### 7.7 主站 UI 对齐验收
@@ -86,6 +86,7 @@
- 影响范围:下一阶段的共享画布包、网站 adapter、`apps/ai-game-creator-shell` 前端与 Tauri Rust 本地持久化;不修改 SpacetimeDB schema,不把草稿或资源布局 sidecar 变成 manifest 业务真相。
- 验证方式:按权威专题的 28 项矩阵覆盖 Web/Tauri 共用源码、新增/精修、生成响应丢失、重复提交、两窗口并发、事务各崩溃点、草稿恢复、切项目/切状态/改选择/改筛选迟到结果和不刷新即时投影。
- 关联文档:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md``docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-07 game-chat 使用单主路径按需补齐美术
- 背景:原 game-chat 把 Supervisor 的意图判断之后又硬接为 `design-director / code-director / art-* / code-prototype / preview-*` 固定图。`code-director` 代替程序主 Agent 判断素材缺口,会让“把现有美术资源应用到游戏中”被错误翻译成先生成美术,且美术回执无法天然回到同一个代码 Run 完成接入。
@@ -1546,6 +1547,7 @@
- 2026-06-19 桌面壳外链打开 helper 共用:Tauri WebView 外域拦截和 HostBridge `app.openExternalUrl` 都必须复用 `open_normalized_desktop_external_url` 执行系统外链打开动作;HostBridge 分支仍先用 `normalize_external_url` 保留 payload 错误语义并把 opener 错误回传给 H5WebView 拦截保持 best-effort 静默处理。桌面壳配置检查会拒绝 `dispatch.rs` 直接调用 `app.opener().open_url` 绕过该 helper,避免两条离壳路径漂移。
> 2026-07-18 覆盖说明:本段后续关于微信 `navigation.openNativePage`、生成结果订阅页、`[subscribe-message]` 日志和订阅页路由门禁的 2026-06 决策均已由旧创作模板退役决策废止,只作为历史记录。Expo / Tauri 的同源 H5 受控导航及微信登录、支付、分享能力继续有效。
- 2026-06-20 H5 原生导航预校验:`navigateHostNativePage()``native_app` 下发送 `navigation.openNativePage` 前必须先拒绝空值、控制字符、协议相对 URL、外域绝对 URL 和非 `http:` / `https:` 协议目标;同源绝对 URL、`/path` 和保留给桌面壳兼容的相对 route 继续交给 Expo / Tauri 壳二次归一并补写宿主上下文。微信小程序分支仍按小程序页面 URL 语义走 `wx.miniProgram.navigateTo`,不套原生 App 同源 H5 预校验。根级 `npm run check:native-shells` 会反查 H5 facade 仍使用 `normalizeNativeAppPageUrl(...)` 且发送归一后的 URL,避免明显不安全目标触达原生壳。
- 2026-06-20 微信受控原生页能力声明:微信小程序壳真实 capability profile 声明 `navigation.openNativePage`,用于承接已经登记并测试的小程序原生页 flow;当前订阅生成结果通知页通过 H5 `requestGenerationResultSubscribePermission()` 调用 `navigateHostNativePage()` 打开 `/pages/subscribe-message/index`,小程序页再调用真实 `wx.requestSubscribeMessage` 并按既有结果协议回灌。根级 `npm run check:native-shells` 必须把该能力反查到共享 profile、微信 `WECHAT_HOST_CAPABILITIES` 镜像、订阅页协议常量、H5 入口、小程序 host-bridge / shell / page 文件和相关测试;该能力不代表开放任意小程序页面跳转。
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities``openHostShare``writeHostClipboardText``requestHostHapticsImpact``setHostAppTitle``exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示受控分享动作,并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达,避免旧壳或裁剪壳露出不可用入口。
@@ -6964,8 +6966,12 @@
- 资源编辑 operation 以私有账本中的原 endpoint、请求字节、幂等键和 `operationId` 为唯一恢复身份;账本同时冻结源 asset、媒体类型、资源 kind、生产任务和源版本快照。旧账本只从当前 manifest、已完成任务产物、版本数组及受控扩展名兼容恢复,不能信任 UI 路径或前端重新构造的来源。
- Developer API Key 允许轮换,当前 `service-origin-v1` 配置身份绑定规范化 External base URL 的服务指纹,确认 UI 只展示服务 origin。升级前包含 Key 的生成指纹在当前 Key 可精确验证原服务时自动迁移;无法验证时保留原 operation 进入对账,不自动接管或重放。素材画布生成恢复另提供受挑战值保护的显式 origin 确认。operation 已建立后遇到 401/403/404、超时或临时网关错误都保留原 operation;恢复命令仅接收项目与 operation 身份,不接收 Key 或新请求正文。
- pending 扫描上限覆盖目录全部实际条目。媒体提交前重新校验源摘要;视频后续派生使用 committed ledger 的稳定 `objectKey`,不把 `assetObjectId` 当远端输入;版本 journal 重放返回原子版本,禁止重复追加。
- refine 草稿恢复以正式 sidecar 为权威,按项目、意图、源素材和 active 状态有界唯一发现;唯一命中保留 `draftId` 并换新 `sessionId`,多候选失败关闭。文本/SVG/Agent 回执在 Provider 调用前持久化 request-issued,成功正文先写 durable handoff 再解析和 stagingissued 后无可信 handoff 只对账,有 handoff 只消费原响应。
- 旧 Key-bound 资源编辑账本采用快照绑定的显式服务 origin 确认,确认前零网络动作;已受理 operation 遇到 401/403 保留原 remote phase,换 Key 后只 GET。asset journal 已证明目标 asset/media 与 target revision 后,后续合法 manifest/revision 前进不阻止补齐 ledger。
- 画布生成中冻结 wheel、指针、缩放和方向键状态改变并聚焦“停止等待”;普通 wheel 二维平移,Shift 纵向滚轮横移,Ctrl/Command wheel 缩放。Shift 指针序列只执行一次选择切换,草稿保存失败后退出仍必须提供保留/放弃确认。
- 只有 `editing` 生命周期允许新生成;其它状态与 modal 同时锁闭背景交互,生成 handler 必须再次读取当前 lifecycle 失败关闭。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs``apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx``packages/image-canvas-core/src/interaction.ts`
## 2026-08-06 编辑器生成结果使用 durable receipt 与统一原子提交
- 背景:图片、改图、去背景、图集 / UI 多产物、角色动作、视频、音效和背景音乐在 OSS 结果可用后,仍分段 confirm object、创建 project resource / account asset、保存 canvas 和 complete job。任一中间失败都会留下部分业务事实;只把 `external_generation_job` 当 operation journal 又无法覆盖无 job 的 inline,也无法独立证明某批 resource/asset/canvas 已作为一笔提交完成。
@@ -4548,6 +4548,7 @@
- 原因:把 Pointer 事件接线、宿主生命周期胶水和可复用 history 算法放在同一组件中,误以为没有复制整个画布目录就已经满足共享源码边界。
- 处理:两宿主直接消费共享 `useCanvasHistory`;共享 snapshot 统一覆盖 viewport、selection、图层位置和 width/height,宿主只声明本地媒体是否允许安全移除/重做。Tauri 仍可保留 Pointer capture/epoch/host callback 接线,但选择、平移、缩放、变换、renderer 和 history 状态机不得在宿主重写。
- 验证:主站 history 定向测试覆盖 resize undo/redoTauri 新建、导入、编辑、撤销重做和 durable commit 用例必须在同一共享 hook 下通过。
## 编辑器生成不能把传输重试、参考图截断和客户端 provenance 当成独立小问题(2026-08-05
- 现象:生成 POST 首次已经入队但响应丢失时,客户端自动重试产生第二个任务;第 6 张或更多参考图仍显示在 UI / 元数据里,却没有送给 provider;直接构造请求还能把任意资源 ID 写成最终素材引用。
@@ -4649,11 +4650,11 @@
## Tauri 生成与资源编辑恢复不能依赖 UI 快照、旧 Key 指纹或队列首项(2026-08-11
- 现象:应用重启后,任务视频项目版本的编辑 operation 无法恢复;轮换 Developer API Key 后已有 operation 被误判为配置变化目录中放入大量无关文件还能绕过 pending 扫描上限。一条远端已明确失败的老 operation 会持续占据队列首项,挡住后续已受理或已下载任务;manifest 已写而 project revision 未写时,又可能被误标为 committed。durable committed 后遗留 staging 可能因一次删除失败而被误报为提交失败,也可能在正式媒体或 manifest 身份已经漂移时被直接删除;manifest 已有派生子版本而 journal 缺失,或旧 journal 没有 revision 身份时,也可能被猜成已经提交。派生视频再次编辑时若把 `assetObjectId` 当远端引用,生成会失败或指向错误身份。
- 原因:早期账本只保存显示层资源 ID,恢复时又依赖当前页面资源对象;配置指纹混入 Key;扫描计数只在识别出 pending JSON 后递增;本地登记 ID 与 External generation 接受的稳定 `objectKey` 没有分层。恢复 UI 只选排序后第一项,而账本又没有远端终态失败/归档阶段;资产提交没有一份同时证明文件、manifest 和 revision 的 durable journal,提交后的清理也没有区分可证明的 staging 与需要保留的对账证据;旧 version journal 只保存 base/target 数值,不能证明完整 project revision before/after 身份。
- 处理:新账本冻结完整源快照,旧账本从权威 manifest、完成任务和版本记录有界恢复;服务身份用 `service-origin-v1` 哈希规范化 External base URL,确认 UI 只展示去除路径与凭据的服务 origin。对素材画布生成账本,当前 Key 不能验证旧 Key-bound 指纹,确认前保持零网络动作,由用户显式确认当前服务后才继续原 GET 或精确重放冻结 POST;对全类型资源编辑,同类旧指纹保留原 operation 进入对账,不自动接管或重放。扫描在读取每个目录条目时先计数,任何文件都消耗预算。提交前复验源摘要,远端请求只使用账本已确认的稳定 `objectKey`,恢复始终复用原 operation 和请求字节。
- 队列与事务:独立恢复面板必须展示后端权威队列的所有 operation,读取失败不能伪装为空。`remote-failed` 不再重放,只能显式标为 `archived` 并保留账本;`reconciliation-required` 不能归档。派生 asset 使用 `prepared -> media-installed -> manifest-written -> revision-written -> committed` journal,只对可证明状态前向恢复;文件/摘要、manifest before/after 或 revision before/after 不匹配时进入人工对账。committed 后只有 staging 与正式媒体摘要一致、manifest 按 ID 或路径唯一精确匹配 journal asset 时才尽力清理;删除 I/O 失败保持 durable committed,身份或媒体漂移保留 staging 并进入对账。version journal 同样冻结 project revision before/after 身份;manifest 已有子版本但 journal 缺失,或旧 journal 面对已推进 revision 无法补证时都失败关闭。
- 验证:覆盖任务视频/版本旧账本恢复、所有目录条目上限、Key 轮换与旧 Key 无法验证时的显式确认、401/403/404 保留 operation、远端明确失败只归档且零新网络/扣费、三条乱序恢复队列、项目切换迟到结果、asset transaction 各崩溃阶段、committed 后 staging 清理成功/删除 I/O 失败/媒体或 manifest 漂移保留、源摘要漂移拒绝、committed 视频二次派生,以及 manifest 子版本缺 journal、旧 version journal 无法证明 revision 推进与 version journal exactly-once。
- 现象:应用重启后,任务视频项目版本或 refine 草稿无法恢复;轮换 Developer API Key 后已有 operation 被误判为配置变化,已受理任务一次 401/403 还可能永久进入对账;文本 Provider 已成功但尚未 staging 时崩溃会重复调用。目录中放入大量无关文件还能绕过 pending 扫描上限。一条远端已明确失败的老 operation 会持续占据队列首项,挡住后续已受理或已下载任务;manifest 已写而 project revision 未写时,又可能被误标为 committed,或者 journal 已证明提交后因项目继续合法修改而无法补 ledger。durable committed 后遗留 staging 可能因一次删除失败而被误报为提交失败,也可能在正式媒体或 manifest 身份已经漂移时被直接删除;manifest 已有派生子版本而 journal 缺失,或旧 journal 没有 revision 身份时,也可能被猜成已经提交。派生视频再次编辑时若把 `assetObjectId` 当远端引用,生成会失败或指向错误身份。
- 原因:早期账本只保存显示层资源 ID,恢复时又依赖当前页面资源对象;refine `draftId` 只在组件 Map;配置指纹混入 Key 并把认证错误写成状态机终态;Provider 正文从内存直接进入解析/staging;扫描计数只在识别出 pending JSON 后递增;本地登记 ID 与 External generation 接受的稳定 `objectKey` 没有分层。恢复 UI 只选排序后第一项,而账本又没有远端终态失败/归档阶段;资产提交恢复把整个历史 after manifest 当作永久相等条件,没有区分目标事务事实与后续合法提交;旧 version journal 只保存 base/target 数值,不能证明完整 project revision before/after 身份。
- 处理:新账本冻结完整源快照,旧账本从权威 manifest、完成任务和版本记录有界恢复;refine 从正式 sidecar 按项目、意图、源素材和 active 状态唯一发现。服务身份用 `service-origin-v1` 哈希规范化 External base URL,确认 UI 只展示去除路径与凭据的服务 origin旧 Key-bound 指纹由快照绑定的显式挑战迁移,确认前零网络动作,已受理任务换 Key 后只 GET 原 operation。Provider 调用前先持久化 request-issued,成功正文再写 durable handoff 后解析/stagingissued 无 handoff 只能对账。扫描在读取每个目录条目时先计数,任何文件都消耗预算。提交前复验源摘要,远端请求只使用账本已确认的稳定 `objectKey`,恢复始终复用原 operation 和请求字节。
- 队列与事务:独立恢复面板必须展示后端权威队列的所有 operation,读取失败不能伪装为空。`remote-failed` 不再重放,只能显式标为 `archived` 并保留账本;`reconciliation-required` 不能归档。派生 asset 使用 `prepared -> media-installed -> manifest-written -> revision-written -> committed` journal,只对可证明状态前向恢复;尚未证明目标写入时严格核对 before/after,已证明目标 asset/media 与 target revision 后允许 manifest/revision 被后续合法提交继续推进,并补齐同一 ledger。committed 后只有 staging 与正式媒体摘要一致、manifest 按 ID 或路径唯一精确匹配 journal asset 时才尽力清理;删除 I/O 失败保持 durable committed,身份或媒体漂移保留 staging 并进入对账。version journal 同样冻结 project revision before/after 身份;manifest 已有子版本但 journal 缺失,或旧 journal 面对已推进 revision 无法补证时都失败关闭。
- 验证:覆盖跨进程唯一 refine 草稿发现和多候选失败关闭、文本 Provider 成功到 staging 崩溃后零重复调用、任务视频/版本旧账本恢复、所有目录条目上限、Key 轮换与旧 Key 无法验证时的显式确认、Accepted 后 401/403 再换 Key 只 GET 原 operation、远端明确失败只归档且零新网络/扣费、三条乱序恢复队列、项目切换迟到结果、asset transaction 各崩溃阶段、revision 后项目继续合法修改仍补齐 ledger、committed 后 staging 清理成功/删除 I/O 失败/媒体或 manifest 漂移保留、源摘要漂移拒绝、committed 视频二次派生,以及 manifest 子版本缺 journal、旧 version journal 无法证明 revision 推进与 version journal exactly-once。
## 无限画布延迟草稿与零位移不能制造新状态(2026-08-11)
@@ -978,8 +978,10 @@ confirmation-required
- 资源总览的“新增资源”保留为明确禁用态,入口处理函数也必须拒绝 create,不能只依赖按钮外观阻止进入空白画布。
- 资源聚焦态的所有现役资源均提供“编辑资源”,覆盖 manifest asset、已完成任务产物、已导入附件、Agent 文本回执和项目版本。后端必须按 manifest、任务完成态、上传登记或回执身份重新核验来源;没有唯一来源身份的本地媒体不得仅凭前端路径进入编辑。
- 静态 PNG / JPEG / WebP 继续使用 `AssetCanvasSurface + intent=refine`,自动加载唯一源图片,并把源资源身份作为图片编辑请求的必选引用。任务产物或附件中的静态图片必须先正规化为正式 manifest asset,再进入现有图片画布。
- refine 入口不能只在 WebView 进程内缓存 `draftId`。每次打开先在正式 draft sidecar 中按 `projectId + intent=refine + sourceAssetId + active status` 有界发现:唯一命中沿用原 `draftId` 并生成新 `sessionId`,零命中才创建,多命中进入 `reconciliation-required``committed/cancelled` 不属于 active 候选。
- 客户端确认编辑后固定调用 `POST /api/external/v1/editor/images/edits`;提示词、比例、尺寸、资源用途、泥点计费和原 operation 恢复继续复用现有生成合同,凭据只来自发布 AppData 私有 `editorApi.apiKey`
- SVG、UTF-8 文档、代码和 Agent 文本回执使用文本差异派生:把源内容当作不可信数据交给当前客户端 LLM,响应必须是完整、唯一的结构化内容 envelope;JSON、SVG 等可校验格式必须在落盘前重新校验。结果写入新的本地路径和 manifest asset,不能直接写回源文件。Agent 回执原记录不转写、不删除,新 asset 以回执资源身份登记血缘。
- 文本、SVG 与 Agent 回执在 Provider 调用前必须先持久化 request-issued;成功响应必须先原子安装到与原 operation、请求指纹和内容摘要绑定的私有 durable handoff,再做 envelope 解析、格式校验和 staging。issued 后缺少可信 handoff 只能对账;handoff 已存在且校验通过时恢复只消费该正文,两种情况都禁止再次调用 Provider。
- 视频使用 `POST /api/external/v1/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 External direct-upload ticket、OSS 表单上传和 confirm,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。
- 音效和背景音乐分别使用 `POST /api/external/v1/editor/audios/sound-effects/generations``POST /api/external/v1/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为“基于原资源语义的派生重制”,界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。
- 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排。
@@ -987,13 +989,15 @@ confirmation-required
- 各类型统一使用稳定 `operationId / Idempotency-Key`。远端 `202` 只表示受理,必须轮询原 operation;提交结果未知或登录失效时保留原身份供恢复,不能换键重提。签名 URL 不得写入 manifestmanifest 只保存稳定 objectKey 对应的资源 / 资产身份。
- External 图片 refine、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;所有 External 路由必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
- 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。
- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed。私有账本保留原 operation、原请求字节和原幂等身份,公开状态进入可恢复对账态;修正 External Editor API Key 配置后只继续同一 operation
- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed 或不可逆 `reconciliation-required`。私有账本保留原 remote phase、operation、原请求字节和原幂等身份;修正 External Editor API Key 配置后,已有 `operationId` 只继续 GET 同一 operation,未受理请求才允许用冻结正文和原幂等键重试 POST
- 全类型资源编辑私有账本必须冻结 `sourceAssetId / sourceMediaType / sourceAssetKind / producerTaskId / sourceVersionId` 源快照;新账本不得依赖 UI 仍停留在原资源,旧账本则只允许从当前 manifest、已完成任务产物、版本数组和受控扩展名兼容恢复。恢复结果仍要重新校验唯一来源、项目归属与媒体类型,不能把账本快照当成越权凭据。
- 私有账本的 External 配置身份只绑定规范服务地址,不绑定可轮换的 Developer API Key。升级前仍使用“服务地址 + Key”指纹的 Agent 生成账本,在原 Key 尚可用时首次恢复必须原子迁移为仅服务地址指纹;迁移后 Key 轮换不影响原 operation,无法证明服务地址未漂移的旧账本仍失败关闭。Key 轮换后,401/403/404、轮询超时或网关暂时失败都必须保留原 endpoint、请求字节、幂等键和 operation恢复命令仅凭持久 `operationId` 查找账本,不允许前端重新拼请求或携带 Key。
- 私有账本的 External 配置身份只绑定规范服务地址,不绑定可轮换的 Developer API Key。升级前仍使用“服务地址 + Key”指纹的 Agent 生成账本,在原 Key 尚可用时首次恢复必须原子迁移为仅服务地址指纹;无法用当前 Key 验证时先返回与账本快照绑定的显式确认挑战,确认前零网络动作,确认时复核 operation、挑战值与当前规范服务地址,成功后原子迁移。迁移后 Key 轮换不影响原 operation401/403/404、轮询超时或网关暂时失败都必须保留原 endpoint、请求字节、幂等键和 operation恢复命令不允许前端重新拼请求或携带 Key。
- pending 恢复扫描的目录上限统计所有实际目录条目,包括未知文件、非 pending 账本和损坏条目;达到上限后停止扫描并返回安全错误,不能通过堆放无关文件绕过边界。已 committed 的账本不得出现在 pending 列表中。
- 派生媒体提交必须以账本冻结的源摘要再次校验源文件;摘要、媒体身份或版本父子关系变化时失败关闭。远端派生视频二次编辑必须复用已提交账本中的稳定 `objectKey`,不能把仅用于本地登记的 `assetObjectId` 当成 generation reference。版本 journal 重放只能返回既有子版本,不得重复追加。
- 返回资源总览时,clean 草稿直接保留并退出;dirty 草稿必须使用独立确认面板提供“保留草稿并退出”和“放弃草稿”,默认保留。保留前必须等待当前保存或主动 flush,放弃才允许调用 discard。
- 图层选择与图层缩放必须同时提供指针和键盘路径;不得嵌套 button/role=button。Enter/Space 可选择图层,缩放手柄使用原生 button 并提供方向键离散缩放。
- Tauri 画布普通滚轮按二维 delta 平移,Shift 加纵向滚轮作为横向平移回退,Ctrl/Command 加滚轮才围绕指针缩放;一次 Shift 指针事件序列只能切换一次选择。生成中必须把焦点移到“停止等待”,并阻止 wheel、指针拖拽、缩放快捷键和方向键继续改变画布,避免生成恢复期间写入新的 UI 快照。
- `editing` 是唯一允许发起新生成的生命周期。`generating/committing/recovering/failed` 及独立 modal 打开时,画布与顶部工具栏必须同时 disabled/inert;生成事件 handler 还要读取当前权威 lifecycle 再次失败关闭,不能依赖遮罩层或渲染时闭包。
- asset transaction journal 已精确证明目标 asset、正式媒体和 target revision 写入时,恢复应允许当前 manifest 包含该目标的同时已有后续合法追加,且只要求 `currentRevision >= targetRevision`;补齐同一 ledger/draft 后仍复用原 commit/event 身份,不能因为整个 manifest 不再等于历史 after 快照而永久对账。
当前阶段不主动扩展 create 专属空画布导入、生成或保存体验;图片继续复用现有画布,其他类型使用同一资源编辑壳按能力分流,不能把视频、音频或版本强塞进图片图层模型。共享持久化、安全、幂等和可访问性缺陷仍必须修复,因为它们直接影响编辑链路。