修复资源画布视口与生成恢复
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Native shell tests (pull_request) Successful in 12m52s

统一资源详情操作样式并恢复空态页面滚动

按排序模式和栏目记忆视口并改进复位适配算法

完善候选确认屏障与连续提交恢复对账

补充前后端回归测试并同步产品和技术文档
This commit is contained in:
2026-08-24 19:34:56 +08:00
parent f9bca1f9ad
commit a5cdd7f936
14 changed files with 1000 additions and 209 deletions
@@ -4102,100 +4102,117 @@ fn find_superseding_asset_canvas_commit(
if final_image_state_for_journal(root, journal)? != AssetCanvasFinalImageState::Matches {
return Ok(false);
}
for later in read_asset_canvas_transaction_journals(root)? {
if later.commit_id == journal.commit_id
|| later.project_id != journal.project_id
|| later.draft_id != journal.draft_id
|| later.asset_id != journal.asset_id
{
continue;
}
let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else {
continue;
};
if !asset_canvas_journal_ledger_identity_matches(&later, &ledger)
|| ledger.status != AssetCanvasLedgerStatus::Committed
|| ledger.committed_project_revision != Some(later.target_project_revision)
|| ledger.committed_draft_revision != Some(later.target_draft_revision)
|| ledger.asset_id.as_deref() != Some(later.asset_id.as_str())
|| ledger.event_payload.is_none()
{
continue;
}
if later.expected_project_revision != journal.target_project_revision
|| later.manifest_before_sha256 != journal.manifest_after_sha256
|| later.project_revision_before_sha256.as_deref()
!= Some(journal.project_revision_after_sha256.as_str())
|| later.final_image_relative_path == journal.final_image_relative_path
|| !matches!(
let journals = read_asset_canvas_transaction_journals(root)?;
let manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&journal.commit_id,
"manifest.after.json",
&journal.manifest_after_sha256,
)?;
let revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&journal.commit_id,
"project-revision.after.json",
&journal.project_revision_after_sha256,
)?;
let mut pending = vec![(journal.clone(), manifest_after, revision_after)];
let mut visited = HashSet::from([journal.commit_id.clone()]);
while let Some((previous, previous_manifest_after, previous_revision_after)) = pending.pop() {
for later in &journals {
if visited.contains(&later.commit_id)
|| later.project_id != previous.project_id
|| later.draft_id != previous.draft_id
|| later.asset_id != previous.asset_id
|| later.expected_project_revision != previous.target_project_revision
|| later.manifest_before_sha256 != previous.manifest_after_sha256
|| later.project_revision_before_sha256.as_deref()
!= Some(previous.project_revision_after_sha256.as_str())
|| later.final_image_relative_path == previous.final_image_relative_path
{
continue;
}
let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else {
continue;
};
let finalized_link = matches!(
later.stage,
AssetCanvasTransactionStage::Committed
| AssetCanvasTransactionStage::EventAttempted
)
{
continue;
}
) && ledger.status == AssetCanvasLedgerStatus::Committed;
let superseded_link = later.stage == AssetCanvasTransactionStage::Superseded
&& ledger.status == AssetCanvasLedgerStatus::Superseded;
if !asset_canvas_journal_ledger_identity_matches(later, &ledger)
|| (!finalized_link && !superseded_link)
|| ledger.committed_project_revision != Some(later.target_project_revision)
|| ledger.committed_draft_revision != Some(later.target_draft_revision)
|| ledger.asset_id.as_deref() != Some(later.asset_id.as_str())
|| ledger.event_payload.is_none()
{
continue;
}
let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.before.json",
&later.manifest_before_sha256,
)?;
let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.before.json",
later
.project_revision_before_sha256
.as_deref()
.ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?,
)?;
let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.after.json",
&later.manifest_after_sha256,
)?;
let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.after.json",
&later.project_revision_after_sha256,
)?;
let later_manifest_before_sha256 =
asset_canvas_sha256(&asset_canvas_json_bytes(&later_manifest_before)?);
let later_revision_before_sha256 =
asset_canvas_sha256(&asset_canvas_json_bytes(&later_revision_before)?);
if later_manifest_before_sha256 != journal.manifest_after_sha256
|| later_revision_before_sha256 != journal.project_revision_after_sha256
|| current_manifest != &later_manifest_after
|| current_manifest_sha256 != later.manifest_after_sha256
|| current_revision != &later_revision_after
|| current_revision_sha256 != later.project_revision_after_sha256
|| current_revision.revision != later.target_project_revision
|| later_revision_after.revision != later.target_project_revision
|| later.target_project_revision != later.expected_project_revision.saturating_add(1)
|| final_image_state_for_journal(root, &later)? != AssetCanvasFinalImageState::Matches
{
continue;
}
let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.before.json",
&later.manifest_before_sha256,
)?;
let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.before.json",
later
.project_revision_before_sha256
.as_deref()
.ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?,
)?;
let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.after.json",
&later.manifest_after_sha256,
)?;
let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.after.json",
&later.project_revision_after_sha256,
)?;
if later_manifest_before != previous_manifest_after
|| later_revision_before != previous_revision_after
|| later_revision_after.revision != later.target_project_revision
|| later.target_project_revision
!= later.expected_project_revision.saturating_add(1)
|| final_image_state_for_journal(root, later)?
!= AssetCanvasFinalImageState::Matches
{
continue;
}
let matching_assets = current_manifest
.assets
.iter()
.filter(|asset| asset.id == later.asset_id)
.collect::<Vec<_>>();
if matching_assets.len() != 1 {
continue;
let reaches_current_state = current_manifest == &later_manifest_after
&& current_manifest_sha256 == later.manifest_after_sha256
&& current_revision == &later_revision_after
&& current_revision_sha256 == later.project_revision_after_sha256
&& current_revision.revision == later.target_project_revision;
if reaches_current_state && finalized_link {
let matching_assets = current_manifest
.assets
.iter()
.filter(|asset| asset.id == later.asset_id)
.collect::<Vec<_>>();
if matching_assets.len() == 1
&& matching_assets[0].local_path == later.final_image_relative_path
&& matching_assets[0].media_type == later.staged_image.media_type
{
return Ok(true);
}
continue;
}
visited.insert(later.commit_id.clone());
pending.push((later.clone(), later_manifest_after, later_revision_after));
}
let asset = matching_assets[0];
if asset.local_path != later.final_image_relative_path
|| asset.media_type != later.staged_image.media_type
{
continue;
}
return Ok(true);
}
Ok(false)
}
@@ -188,7 +188,7 @@ fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) {
&fixture,
&draft,
&staged,
Uuid::new_v4().to_string(),
"f0000000-0000-4000-8000-000000000001".to_string(),
Uuid::new_v4().to_string(),
);
first_input.source_layer_id = Some(first_layer_id.clone());
@@ -207,7 +207,7 @@ fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) {
&fixture,
&draft_before_second,
&staged,
Uuid::new_v4().to_string(),
"10000000-0000-4000-8000-000000000002".to_string(),
Uuid::new_v4().to_string(),
);
second_input.source_layer_id = Some(second_layer_id);
@@ -1270,6 +1270,65 @@ fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit()
}));
}
#[test]
fn recovery_follows_verified_refine_commit_chain_to_current_state() {
let (fixture, first_commit_id, second_commit_id) =
initialize_refine_fixture_with_later_commit();
let mut first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id)
.expect("read first transaction before third commit")
.expect("first transaction retained");
let mut first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id)
.expect("read first ledger before third commit")
.expect("first ledger retained");
first_journal.stage = AssetCanvasTransactionStage::Committed;
first_ledger.status = AssetCanvasLedgerStatus::Committed;
write_asset_canvas_journal(fixture.root(), &first_journal)
.expect("restore first journal before third commit");
write_asset_canvas_ledger(fixture.root(), &first_ledger)
.expect("restore first ledger before third commit");
let draft_after_second =
read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id)
.expect("read draft after second commit")
.expect("draft retained after second commit");
let (draft_before_third, third_layer_id, third_media_sha256) =
add_candidate_layer(&fixture, &draft_after_second);
let staged = stage_image(&fixture, &draft_before_third);
let mut third_input = commit_input(
&fixture,
&draft_before_third,
&staged,
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
third_input.source_layer_id = Some(third_layer_id);
third_input.media_sha256 = Some(third_media_sha256);
let third_commit_id = third_input.commit_id.clone();
commit_asset_canvas_at(fixture.root(), &third_input).expect("commit third final image");
force_refine_transaction_unresolved(&fixture, &first_commit_id);
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover three chained refine commits");
for commit_id in [&first_commit_id, &second_commit_id] {
assert!(recovered.result.outcomes.iter().any(|outcome| {
outcome.commit_id == *commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded
}));
}
assert!(recovered.result.outcomes.iter().any(|outcome| {
outcome.commit_id == third_commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::AlreadyCommitted
}));
assert!(!recovered.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("repeat chained refine recovery");
assert!(!repeated.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
}
#[test]
fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() {
let (fixture, first_commit_id, second_commit_id) =
@@ -843,6 +843,7 @@ export function AssetCanvasSurface({
const dragRef = useRef<DragState | null>(null);
const suppressNextLayerClickRef = useRef(false);
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const pendingCandidateLayerIdsRef = useRef(new Set<string>());
const savePromiseRef = useRef<Promise<void> | null>(null);
const hostRevisionRef = useRef(expectedHostRevision);
const previewUrlsRef = useRef(new Set<string>());
@@ -1172,6 +1173,7 @@ export function AssetCanvasSurface({
const epoch = epochRef.current + 1;
epochRef.current = epoch;
saveQueueRef.current = Promise.resolve();
pendingCandidateLayerIdsRef.current.clear();
savePromiseRef.current = null;
pendingCommitRef.current = null;
pendingGenerationRef.current = null;
@@ -1267,6 +1269,28 @@ export function AssetCanvasSurface({
}
await hydrateDraft(nextDraft, epoch);
if (epoch !== epochRef.current) return;
if (
nextDraft.generations.some(
(generation) => generation.phase === 'candidate-ready',
)
) {
const acknowledged = await host.project.acknowledgeCandidateLayers({
scope: stableScope,
// The host ignores non-candidate and already acknowledged IDs. This
// closes the restart window without exposing private ledger state.
layerIds: nextDraft.canvas.layers.map((layer) => layer.layerId),
});
if (epoch !== epochRef.current) return;
if (acknowledged.status !== 'ok') {
throw new Error(
acknowledged.status === 'failed' ||
acknowledged.status === 'unsupported-capability'
? acknowledged.message
: '恢复候选图层确认发生草稿冲突',
);
}
applyDraftCandidate(acknowledged.value);
}
// 生成 operation 的恢复可能持续数分钟。画布草稿已安全 hydrate 后应立即可编辑,
// 后台恢复只更新任务投影,不能把整个画布继续锁在 recovering。
@@ -1350,6 +1374,7 @@ export function AssetCanvasSurface({
previewUrls.clear();
};
}, [
applyDraftCandidate,
applyGenerationProgressRevision,
expectedHostRevision,
host,
@@ -1463,13 +1488,64 @@ export function AssetCanvasSurface({
return () => observer.disconnect();
}, [draft]);
const persistDraft =
useCallback(async (): Promise<ImageCanvasDraft | null> => {
const acknowledgePendingCandidateLayers = useCallback(
async (epoch: number): Promise<ImageCanvasDraft | null> => {
const currentDraft = draftRef.current;
const layerIds = [...pendingCandidateLayerIdsRef.current];
if (!currentDraft || epoch !== epochRef.current) return null;
if (layerIds.length === 0) return currentDraft;
const result = await host.project.acknowledgeCandidateLayers({
scope: stableScope,
layerIds,
});
if (epoch !== epochRef.current) return null;
if (result.status !== 'ok') {
setLifecycle({
kind: 'canvas.failed',
operation: 'draft-save',
code: result.status === 'failed' ? result.code : result.status,
message:
result.status === 'failed' ||
result.status === 'unsupported-capability'
? result.message
: '候选图层确认发生草稿冲突',
reconciliationRequired: false,
});
return null;
}
for (const layerId of layerIds) {
pendingCandidateLayerIdsRef.current.delete(layerId);
}
applyDraftCandidate(result.value);
return result.value;
},
[applyDraftCandidate, host.project, stableScope],
);
const enqueuePendingCandidateAcknowledgements = useCallback(async () => {
const epoch = epochRef.current;
const task = saveQueueRef.current.then(() =>
acknowledgePendingCandidateLayers(epoch),
);
saveQueueRef.current = task.catch(() => undefined);
return await task;
}, [acknowledgePendingCandidateLayers]);
const persistDraft = useCallback(
async (
candidateLayerIds: readonly string[] = [],
): Promise<ImageCanvasDraft | null> => {
const epoch = epochRef.current;
const requestedVersion = documentVersionRef.current;
const task = saveQueueRef.current.then(async () => {
const currentDraft = draftRef.current;
if (!currentDraft || epoch !== epochRef.current) return null;
// Register a candidate only when its own save reaches the head of the
// FIFO. Registering at enqueue time would let an older autosave
// acknowledge it before this save has written the latest canvas.
for (const layerId of candidateLayerIds) {
pendingCandidateLayerIdsRef.current.add(layerId);
}
const result = await host.project.updateDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
@@ -1509,17 +1585,42 @@ export function AssetCanvasSurface({
persistedDocumentVersionRef.current,
requestedVersion,
);
const acknowledged = await acknowledgePendingCandidateLayers(epoch);
if (!acknowledged) return null;
if (
requestedVersion === documentVersionRef.current &&
lifecycleRef.current.kind === 'canvas.editing'
) {
setLifecycle({ kind: 'canvas.editing', dirty: false });
}
return result.value;
return acknowledged;
});
saveQueueRef.current = task.catch(() => undefined);
return await task;
}, [applyDraftCandidate, host.project, stableScope]);
},
[
acknowledgePendingCandidateLayers,
applyDraftCandidate,
host.project,
stableScope,
],
);
const flushDraftPersistence = useCallback(
async ({ persistDirty = true }: { persistDirty?: boolean } = {}) => {
const epoch = epochRef.current;
await saveQueueRef.current;
if (epoch !== epochRef.current || !draftRef.current) return null;
if (
persistDirty &&
documentVersionRef.current !== persistedDocumentVersionRef.current
) {
return await persistDraft();
}
return await enqueuePendingCandidateAcknowledgements();
},
[enqueuePendingCandidateAcknowledgements, persistDraft],
);
useEffect(() => {
if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) {
@@ -1697,13 +1798,7 @@ export function AssetCanvasSurface({
) {
return;
}
if (
documentVersionRef.current !== persistedDocumentVersionRef.current &&
!(await persistDraft())
) {
return;
}
const currentDraft = draftRef.current;
const currentDraft = await flushDraftPersistence();
if (!currentDraft || lifecycleRef.current.kind !== 'canvas.editing') return;
const epoch = epochRef.current;
const historySnapshot = getCanvasHistorySnapshot();
@@ -1809,7 +1904,7 @@ export function AssetCanvasSurface({
captureHistory,
getCanvasHistorySnapshot,
host,
persistDraft,
flushDraftPersistence,
stableScope,
]);
@@ -1817,11 +1912,10 @@ export function AssetCanvasSurface({
async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
const currentDraft = draftRef.current;
if (
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current ||
!currentDraft ||
!draftRef.current ||
!files.length
)
return;
@@ -1854,6 +1948,15 @@ export function AssetCanvasSurface({
) {
return;
}
const currentDraft = await flushDraftPersistence();
if (
!currentDraft ||
epoch !== epochRef.current ||
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current
) {
return;
}
const imported = await host.asset.importImages({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
@@ -1927,7 +2030,7 @@ export function AssetCanvasSurface({
markDirty();
setNotice(`已导入 ${additions.length} 张图片`);
},
[captureHistory, host.asset, markDirty, stableScope],
[captureHistory, flushDraftPersistence, host.asset, markDirty, stableScope],
);
const deleteCanvasLayer = useCallback(
@@ -2024,12 +2127,11 @@ export function AssetCanvasSurface({
});
const task = (async () => {
const epoch = saveEpoch;
if (!draftRef.current) return;
if (documentVersionRef.current !== persistedDocumentVersionRef.current) {
setLifecycle({ kind: 'canvas.saving', stage: 'draft' });
const persisted = await persistDraft();
if (!persisted) return;
}
const persisted = await flushDraftPersistence();
if (!persisted) return;
if (epoch !== epochRef.current || !draftRef.current) return;
setLifecycle({ kind: 'canvas.saving', stage: 'committing' });
const referenceResourceIds = draftRef.current.sourceResourceId
@@ -2156,7 +2258,7 @@ export function AssetCanvasSurface({
hydrateDraft,
onCommitted,
onSaveAttempt,
persistDraft,
flushDraftPersistence,
quickEditSourceLayerId,
renderImage,
sessionId,
@@ -2164,9 +2266,8 @@ export function AssetCanvasSurface({
]);
const discardCanvas = useCallback(() => {
const currentDraft = draftRef.current;
if (
!currentDraft ||
!draftRef.current ||
!['canvas.editing', 'canvas.failed'].includes(
lifecycleRef.current.kind,
) ||
@@ -2178,13 +2279,16 @@ export function AssetCanvasSurface({
}
setExitActionPending(true);
const epoch = epochRef.current;
void host.project
.discardDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
void flushDraftPersistence({ persistDirty: false })
.then((currentDraft) => {
if (!currentDraft || epoch !== epochRef.current) return null;
return host.project.discardDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
});
})
.then((result) => {
if (epoch !== epochRef.current) return;
if (!result || epoch !== epochRef.current) return;
if (result.status === 'ok') {
setExitDialogOpen(false);
onCancel?.({
@@ -2219,7 +2323,7 @@ export function AssetCanvasSurface({
.finally(() => {
if (epoch === epochRef.current) setExitActionPending(false);
});
}, [host.project, onCancel, stableScope]);
}, [flushDraftPersistence, host.project, onCancel, stableScope]);
const keepDraftAndExit = useCallback(() => {
if (
@@ -2237,7 +2341,7 @@ export function AssetCanvasSurface({
if (!currentDraft) return;
setExitActionPending(true);
const epoch = epochRef.current;
void persistDraft()
void flushDraftPersistence()
.then((persisted) => {
if (epoch !== epochRef.current || !persisted) return;
setExitDialogOpen(false);
@@ -2249,7 +2353,7 @@ export function AssetCanvasSurface({
.finally(() => {
if (epoch === epochRef.current) setExitActionPending(false);
});
}, [exitActionPending, onCancel, persistDraft, stableScope.draftId]);
}, [exitActionPending, flushDraftPersistence, onCancel, stableScope.draftId]);
const requestCanvasExit = useCallback(() => {
const currentDraft = draftRef.current;
@@ -2586,15 +2690,17 @@ export function AssetCanvasSurface({
return true;
};
const task = (async () => {
if (needsDraftPersist) {
const persisted = await persistDraft();
if (!persisted) {
const persisted = needsDraftPersist
? await persistDraft()
: await flushDraftPersistence();
if (!persisted) {
if (needsDraftPersist) {
await persistGenerationFailure(
'draft-save-failed',
'生成任务占位保存失败',
);
return;
}
return;
}
const currentDraft = draftRef.current;
if (
@@ -2755,10 +2861,16 @@ export function AssetCanvasSurface({
setGenerationTasks(
mergedGenerations.map(runtimeGenerationTaskFromRecord),
);
// Acknowledge the authoritative candidate with a canvas save. Until
// this first save succeeds, the backend keeps merging the new layer
// back into stale autosaves that were queued while generation ran.
if (!(await persistDraft())) return;
// Save the authoritative candidate, then acknowledge its private ledger
// in the same FIFO. Until both steps succeed, the backend keeps merging
// the new layer back into stale autosaves that were queued during generation.
if (
!(await persistDraft(
authoritativeLayers.map((layer) => layer.layerId),
))
) {
return;
}
pendingGenerationRef.current = null;
persistedDocumentVersionRef.current = documentVersionRef.current;
setLifecycle({ kind: 'canvas.editing', dirty: false });
@@ -2793,6 +2905,7 @@ export function AssetCanvasSurface({
generationPrompt,
generationReferenceResourceIds,
generationTasks,
flushDraftPersistence,
host.generation,
onSaveAttempt,
onWalletBalanceMayHaveChanged,
@@ -2815,13 +2928,7 @@ export function AssetCanvasSurface({
) {
return;
}
if (
documentVersionRef.current !== persistedDocumentVersionRef.current &&
!(await persistDraft())
) {
return;
}
const currentDraft = draftRef.current;
const currentDraft = await flushDraftPersistence();
if (!currentDraft) return;
const epoch = epochRef.current;
setArchivingGenerationIds((current) => [...current, generationId]);
@@ -2857,9 +2964,9 @@ export function AssetCanvasSurface({
},
[
archivingGenerationIds,
flushDraftPersistence,
generationTasks,
host.generation,
persistDraft,
stableScope,
],
);
@@ -154,8 +154,7 @@ export type TauriImageCanvasHostAdapter = Omit<
'project'
> & {
readonly kind: 'tauri';
readonly project: ImageCanvasProjectPort &
Required<Pick<ImageCanvasProjectPort, 'acknowledgeCandidateLayers'>>;
readonly project: ImageCanvasProjectPort;
readonly projectPath: string;
readonly expectedProjectId: string;
readMediaPreview(input: {
+1 -1
View File
@@ -6110,7 +6110,7 @@ iframe.preview-frame {
flex: 0 0 auto;
}
.game-resource-focus-actions > button:first-child {
.game-resource-focus-action {
min-height: 32px;
padding: 0 14px;
border: 1px solid #d78d69;
@@ -1,7 +1,6 @@
import type { ImageCanvasHostScope } from '@genarrative/image-canvas-core';
import {
type CanvasViewport,
fitViewportToBounds,
resolveViewportFromWheel,
} from '@genarrative/image-canvas-core';
import {
@@ -77,6 +76,7 @@ import {
} from './projectResourceLiveUpdateModel';
import {
createResourceCanvasCardSizeByResourceId,
fitResourceCanvasViewportToContent,
normalizeInfiniteResourceCanvasViewport,
RESOURCE_CANVAS_DRAG_THRESHOLD,
RESOURCE_CANVAS_FIT_PADDING,
@@ -85,6 +85,7 @@ import {
RESOURCE_CANVAS_SECTION_ORDER,
type ResourceCanvasCardSize,
resourceCanvasCardSize,
resourceCanvasContentBounds,
resourceCanvasSectionExtent,
} from './resourceCanvasLayoutModel';
import {
@@ -302,14 +303,22 @@ type ResourceCanvasViewportByCategory = Record<
ResourceCategory,
CanvasViewport
>;
type ResourceCanvasViewportBySortMode = Record<
ResourceSortMode,
ResourceCanvasViewportByCategory
>;
function defaultResourceCanvasViewports(): ResourceCanvasViewportByCategory {
return {
function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode {
const byCategory = (): ResourceCanvasViewportByCategory => ({
document: { x: 48, y: 48, scale: 1 },
art: { x: 48, y: 48, scale: 1 },
audio: { x: 48, y: 48, scale: 1 },
code: { x: 48, y: 48, scale: 1 },
version: { x: 48, y: 48, scale: 1 },
});
return {
dependency: byCategory(),
type: byCategory(),
};
}
@@ -829,7 +838,7 @@ export default function ProjectDevelopmentView({
const [sortMode, setSortMode] = useState<ResourceSortMode>('dependency');
const [searchText, setSearchText] = useState('');
const [resourceCanvasViewports, setResourceCanvasViewports] =
useState<ResourceCanvasViewportByCategory>(defaultResourceCanvasViewports);
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
const [activeResourceCategory, setActiveResourceCategory] =
useState<ResourceCategory | null>(null);
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(
@@ -896,7 +905,7 @@ export default function ProjectDevelopmentView({
y: 48,
scale: 1,
});
const resourceCanvasFitKeyRef = useRef<string | null>(null);
const resourceCanvasFitKeysRef = useRef<Set<string>>(new Set());
const resourceCanvasPageWheelRef = useRef<{
activeCategory: ResourceCategory | null;
accumulatedDeltaY: number;
@@ -1476,8 +1485,28 @@ export default function ProjectDevelopmentView({
activePageExtent.y,
],
);
const activePageResources = activePageCategory
? (projectResourcesByCategory.get(activePageCategory) ?? [])
: [];
const activePageLayoutReady = activePageResources.every((resource) =>
resourcePositionById.has(resource.id),
);
const resourceCanvasFitBounds = useMemo(
() =>
resourceCanvasContentBounds(
activePageCategory
? (resourcePositionsByCategory.get(activePageCategory) ?? [])
: [],
resourceCardSizeByResourceId,
),
[
activePageCategory,
resourceCardSizeByResourceId,
resourcePositionsByCategory,
],
);
const activeResourceCanvasViewport = activePageCategory
? resourceCanvasViewports[activePageCategory]
? resourceCanvasViewports[sortMode][activePageCategory]
: { x: 48, y: 48, scale: 1 };
const setResourceCanvasViewport = useCallback(
(candidate: CanvasViewport) => {
@@ -1488,13 +1517,23 @@ export default function ProjectDevelopmentView({
}
const next = normalizeInfiniteResourceCanvasViewport(candidate);
resourceCanvasViewportRef.current = next;
setResourceCanvasViewports((current) =>
resourceCanvasViewportsEqual(current[activeCategory], next)
setResourceCanvasViewports((current) => {
const currentSortViewports = current[sortMode];
return resourceCanvasViewportsEqual(
currentSortViewports[activeCategory],
next,
)
? current
: { ...current, [activeCategory]: next },
);
: {
...current,
[sortMode]: {
...currentSortViewports,
[activeCategory]: next,
},
};
});
},
[],
[sortMode],
);
resourceCanvasViewportRef.current = activeResourceCanvasViewport;
const selectedResource =
@@ -2019,7 +2058,11 @@ export default function ProjectDevelopmentView({
]);
useLayoutEffect(() => {
if (mode !== 'resources' || !activePageCategory) {
if (
mode !== 'resources' ||
!activePageCategory ||
!activePageLayoutReady
) {
return undefined;
}
const canvas = resourceCanvasRef.current;
@@ -2027,38 +2070,46 @@ export default function ProjectDevelopmentView({
return undefined;
}
const fitKey = `${projectPath}\n${manifest.projectId}\n${sortMode}\n${activePageCategory}`;
const canvasSize = resourceCanvasElementSize(canvas);
if (resourceCanvasFitKeyRef.current !== fitKey) {
resourceCanvasFitKeyRef.current = fitKey;
setResourceCanvasViewport(
fitViewportToBounds({
bounds: resourceCanvasNavigationBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
} else {
const fitInitialViewportOrPreserve = () => {
if (!resourceCanvasFitKeysRef.current.has(fitKey)) {
const viewportElement = resourceCanvasViewportElement(canvas);
const rect = viewportElement?.getBoundingClientRect();
const measuredWidth = viewportElement?.clientWidth || rect?.width || 0;
const measuredHeight =
viewportElement?.clientHeight || rect?.height || 0;
if (measuredWidth > 0 && measuredHeight > 0) {
resourceCanvasFitKeysRef.current.add(fitKey);
setResourceCanvasViewport(
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize: { width: measuredWidth, height: measuredHeight },
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
return;
}
}
// Resource extents can change after an image finishes measuring or a
// card is manually moved. Keep the user's infinite pan/zoom unchanged.
setResourceCanvasViewport(resourceCanvasViewportRef.current);
}
const normalizeViewport = () =>
setResourceCanvasViewport(resourceCanvasViewportRef.current);
};
fitInitialViewportOrPreserve();
const observer = window.ResizeObserver
? new window.ResizeObserver(normalizeViewport)
? new window.ResizeObserver(fitInitialViewportOrPreserve)
: null;
observer?.observe(canvas);
window.addEventListener('resize', normalizeViewport);
window.addEventListener('resize', fitInitialViewportOrPreserve);
return () => {
observer?.disconnect();
window.removeEventListener('resize', normalizeViewport);
window.removeEventListener('resize', fitInitialViewportOrPreserve);
};
}, [
activePageCategory,
activePageLayoutReady,
manifest.projectId,
mode,
projectPath,
resourceCanvasNavigationBounds,
resourceCanvasFitBounds,
setResourceCanvasViewport,
sortMode,
]);
@@ -2199,10 +2250,10 @@ export default function ProjectDevelopmentView({
setFocusedResourceId(null);
restoreResourceListScrollRef.current = false;
setActiveResourceCategory(null);
setResourceCanvasViewports(defaultResourceCanvasViewports());
resourceCanvasFitKeyRef.current = null;
resourceCanvasViewportRef.current =
defaultResourceCanvasViewports().document;
const defaultViewports = defaultResourceCanvasViewports();
setResourceCanvasViewports(defaultViewports);
resourceCanvasFitKeysRef.current.clear();
resourceCanvasViewportRef.current = defaultViewports.dependency.document;
if (resourceCanvasPageWheelRef.current.pendingTimerId !== null) {
window.clearTimeout(resourceCanvasPageWheelRef.current.pendingTimerId);
}
@@ -2653,13 +2704,13 @@ export default function ProjectDevelopmentView({
const resetResourceCanvasViewport = useCallback(() => {
const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current);
setResourceCanvasViewport(
fitViewportToBounds({
bounds: resourceCanvasNavigationBounds,
fitResourceCanvasViewportToContent({
bounds: resourceCanvasFitBounds,
canvasSize,
padding: RESOURCE_CANVAS_FIT_PADDING,
}),
);
}, [resourceCanvasNavigationBounds, setResourceCanvasViewport]);
}, [resourceCanvasFitBounds, setResourceCanvasViewport]);
const handleResourceCardPointerDown = useCallback(
(
@@ -4119,6 +4170,7 @@ export default function ProjectDevelopmentView({
) ? (
<button
type="button"
className="game-resource-focus-action"
onClick={() =>
openCharacterAnimation(focusedResource)
}
@@ -4128,6 +4180,7 @@ export default function ProjectDevelopmentView({
) : null}
<button
type="button"
className="game-resource-focus-action"
onClick={() => openResourceEditor(focusedResource)}
>
@@ -4462,7 +4515,11 @@ export default function ProjectDevelopmentView({
) : null}
<div
ref={resourceCanvasRef}
className={`game-resource-canvas game-resource-canvas--paged game-resource-canvas--${sortMode}`}
className={`game-resource-canvas${
resourcePageCategories.length > 0
? ` game-resource-canvas--paged game-resource-canvas--${sortMode}`
: ''
}`}
aria-label={
sortMode === 'dependency' ? '资源依赖视图' : '资源类型视图'
}
@@ -4583,6 +4640,7 @@ export default function ProjectDevelopmentView({
<div
className="game-resource-page-world"
data-resource-boundary={`${resourceCanvasNavigationBounds.x},${resourceCanvasNavigationBounds.y},${resourceCanvasNavigationBounds.width},${resourceCanvasNavigationBounds.height}`}
data-resource-fit-boundary={`${resourceCanvasFitBounds.x},${resourceCanvasFitBounds.y},${resourceCanvasFitBounds.width},${resourceCanvasFitBounds.height}`}
data-resource-viewport={`${activeResourceCanvasViewport.x},${activeResourceCanvasViewport.y},${activeResourceCanvasViewport.scale}`}
style={{
width: `${resourceCanvasNavigationBounds.width}px`,
@@ -34,7 +34,7 @@ export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
* Initial fit inset only. The resource canvas background itself is infinite;
* this value must not be used to clamp a later pan or zoom.
*/
export const RESOURCE_CANVAS_FIT_PADDING = 96;
export const RESOURCE_CANVAS_FIT_PADDING = 16;
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'code', 'version'];
@@ -1298,3 +1298,76 @@ export function resourceCanvasSectionExtent(
height: Math.ceil(maxY - minY),
};
}
export function resourceCanvasContentBounds(
positions: readonly ProjectResourceCanvasPosition[],
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
) {
if (positions.length === 0) {
return {
x: 0,
y: 0,
width: RESOURCE_CANVAS_SECTION_MIN_WIDTH,
height: RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
};
}
const minX = Math.min(...positions.map((position) => position.x));
const minY = Math.min(...positions.map((position) => position.y));
const maxX = Math.max(
...positions.map(
(position) =>
position.x +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.width,
),
);
const maxY = Math.max(
...positions.map(
(position) =>
position.y +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.height,
),
);
return {
x: Math.floor(minX),
y: Math.floor(minY),
width: Math.max(1, Math.ceil(maxX - minX)),
height: Math.max(1, Math.ceil(maxY - minY)),
};
}
export function fitResourceCanvasViewportToContent({
bounds,
canvasSize,
padding = RESOURCE_CANVAS_FIT_PADDING,
}: {
bounds: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
padding?: number;
}): CanvasViewport {
const normalizedPadding = Math.max(0, padding);
const boundsWidth = Math.max(1, bounds.width);
const boundsHeight = Math.max(1, bounds.height);
const availableWidth = Math.max(1, canvasSize.width - normalizedPadding * 2);
const availableHeight = Math.max(
1,
canvasSize.height - normalizedPadding * 2,
);
const scale = clampResourceCanvasNumber(
Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight),
MIN_SCALE,
MAX_SCALE,
);
return {
x:
normalizedPadding +
availableWidth / 2 -
(bounds.x + boundsWidth / 2) * scale,
y:
normalizedPadding +
availableHeight / 2 -
(bounds.y + boundsHeight / 2) * scale,
scale,
};
}
@@ -768,6 +768,142 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByRole('article', { name: /发布 Agent/ })).not.toBeNull();
});
it('preserves independent art viewports across sort and workbench mode switches', async () => {
const manifest = createGameCreationAppManifest(
'workbench-art-viewport-memory',
'美术资源视口记忆',
);
const codeTask = manifest.tasks.find((task) => task.id === 'code-prototype');
expect(codeTask).toBeDefined();
codeTask!.status = 'completed';
manifest.assets = [
{
id: 'entry-art',
kind: 'character',
mediaType: 'image/png',
localPath: 'assets/entry-art.png',
source: { kind: 'generated', taskId: 'art-asset-plan' },
},
];
window.__TAURI__ = {
core: {
invoke: vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
return resourceGraphForInputs(args);
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 0,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: args?.expectedProjectId,
mode: args?.mode,
revision: 1,
positions: args?.positions,
updatedAt: 1,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
),
},
};
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-art-viewport-memory',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
supervisor: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
const artCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
const readViewport = (canvas: HTMLElement) =>
canvas
.querySelector<HTMLElement>('[data-resource-viewport]')
?.getAttribute('data-resource-viewport');
const zoomOut = (deltaY: number) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
ctrlKey: true,
deltaY,
clientX: 80,
clientY: 60,
});
act(() => {
expect(
screen
.getByRole('button', { name: '复位资源画布' })
.dispatchEvent(event),
).toBe(false);
});
};
zoomOut(120);
const dependencyViewport = readViewport(artCanvas);
expect(dependencyViewport).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const typeCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(typeCanvas)).not.toBe(dependencyViewport),
);
zoomOut(240);
zoomOut(240);
const typeViewport = readViewport(typeCanvas);
expect(typeViewport).toBeTruthy();
expect(typeViewport).not.toBe(dependencyViewport);
fireEvent.click(screen.getByRole('button', { name: '按依赖' }));
const restoredDependencyCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(restoredDependencyCanvas)).toBe(dependencyViewport),
);
fireEvent.click(screen.getByRole('button', { name: '按类型' }));
const restoredTypeCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(restoredTypeCanvas)).toBe(typeViewport),
);
fireEvent.click(screen.getByRole('tab', { name: '运行' }));
expect(screen.getByRole('region', { name: '运行表现层' })).not.toBeNull();
fireEvent.click(screen.getByRole('tab', { name: '资源管理' }));
const restoredArtCanvas = await screen.findByRole('region', {
name: '美术资源资源画布',
});
await waitFor(() =>
expect(readViewport(restoredArtCanvas)).toBe(typeViewport),
);
});
it('uses one full-page canvas per resource section with dependency-only guide lines', async () => {
function addSectionResources(
manifest: ReturnType<typeof createGameCreationAppManifest>,
@@ -959,16 +1095,22 @@ export function registerProjectWorkbenchFoundationTests() {
'48,48,1',
),
);
const fittedScale = Number(
documentWorld?.getAttribute('data-resource-viewport')?.split(',')[2],
);
expect(fittedScale).toBeLessThan(0.6);
const readViewport = () =>
(documentWorld?.getAttribute('data-resource-viewport') ?? '')
.split(',')
.map(Number);
const readFitBounds = () =>
(documentWorld?.getAttribute('data-resource-fit-boundary') ?? '')
.split(',')
.map(Number);
const fittedViewport = readViewport();
const fittedBounds = readFitBounds();
expect(
Math.abs(fittedBounds[2]! * fittedViewport[2]! - 468) < 1 ||
Math.abs(fittedBounds[3]! * fittedViewport[2]! - 268) < 1,
).toBe(true);
const viewportBeforeZoom = readViewport();
const viewportBeforeZoom = fittedViewport;
const zoomWheel = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
@@ -1261,6 +1403,16 @@ export function registerProjectWorkbenchFoundationTests() {
const assertOverview = () => {
expect(screen.queryByLabelText('资源栏目大纲')).toBeNull();
expect(
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--paged'),
).toBe(false);
expect(
screen
.getByLabelText(/资源(?:依赖|类型)视图/)
.classList.contains('game-resource-canvas--dependency'),
).toBe(false);
expect(screen.getAllByText('暂无已登记资源')).toHaveLength(5);
for (const label of [
'设计文档',
@@ -1535,6 +1687,12 @@ export function registerProjectWorkbenchFoundationTests() {
expect(within(heroFocus).getByText('assets/hero.png')).not.toBeNull();
expect(within(heroFocus).getByText('Agent 生成')).not.toBeNull();
expect(within(heroFocus).getByText('image/png')).not.toBeNull();
expect(
within(heroFocus).getByRole('button', { name: '生成动画' }).className,
).toContain('game-resource-focus-action');
expect(
within(heroFocus).getByRole('button', { name: '编辑资源' }).className,
).toContain('game-resource-focus-action');
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_image_preview',
@@ -3532,6 +3690,18 @@ export function registerProjectWorkbenchFoundationTests() {
expect(card?.getAttribute('style')).toContain('--resource-y: 24px');
});
expect(card).not.toBeNull();
const viewportElement = card?.closest<HTMLElement>(
'[data-resource-viewport]',
);
const viewportScale = Number(
viewportElement
?.getAttribute('data-resource-viewport')
?.split(',')
.at(2),
);
expect(viewportScale).toBeGreaterThan(0);
const expectedX = Math.round(12 + (-100 - 20) / viewportScale);
const expectedY = Math.round(24 + (60 - 30) / viewportScale);
const setCardPointerCapture = vi.fn();
Object.defineProperties(card, {
setPointerCapture: { configurable: true, value: setCardPointerCapture },
@@ -3560,8 +3730,12 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.queryByRole('dialog', { name: '布局持久化回执' })).toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: -108px');
expect(card?.getAttribute('style')).toContain('--resource-y: 54px');
expect(card?.getAttribute('style')).toContain(
`--resource-x: ${expectedX}px`,
);
expect(card?.getAttribute('style')).toContain(
`--resource-y: ${expectedY}px`,
);
});
expect(invoke).toHaveBeenCalledWith(
'update_local_project_resource_canvas_layout',
@@ -3570,8 +3744,8 @@ export function registerProjectWorkbenchFoundationTests() {
expect.objectContaining({
resourceId,
section: 'document',
x: -108,
y: 54,
x: expectedX,
y: expectedY,
manuallyPlaced: true,
}),
]),
@@ -177,6 +177,8 @@ function memoryHost(input?: {
importGate?: Deferred<unknown>;
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
candidateAcknowledgementGate?: Deferred<void>;
initialUnacknowledgedCandidateLayerIds?: string[];
generationFailure?: { code: string; message: string };
generationFailureNotStarted?: boolean;
updateFailure?: { code: string; message: string };
@@ -196,6 +198,13 @@ function memoryHost(input?: {
| ((progress: ImageCanvasGenerationProgress) => void)
| undefined;
const updates: ImageCanvasDraftCanvas[] = [];
const persistenceOperations: Array<
| { kind: 'update'; canvas: ImageCanvasDraftCanvas }
| { kind: 'acknowledge'; layerIds: string[] }
> = [];
const unacknowledgedCandidateLayerIds = new Set(
input?.initialUnacknowledgedCandidateLayerIds ?? [],
);
const commits: Array<{
commitId: string;
idempotencyKey: string;
@@ -293,18 +302,52 @@ function memoryHost(input?: {
hostRevision: String(hostRevision),
};
}
updates.push(update.canvas);
const protectedLayers = [...update.canvas.layers];
for (const layerId of unacknowledgedCandidateLayerIds) {
const candidateLayer = draft.canvas.layers.find(
(layer) => layer.layerId === layerId,
);
if (
candidateLayer &&
!protectedLayers.some((layer) => layer.layerId === layerId)
) {
protectedLayers.push(candidateLayer);
}
}
const protectedCanvas = { ...update.canvas, layers: protectedLayers };
updates.push(protectedCanvas);
persistenceOperations.push({ kind: 'update', canvas: protectedCanvas });
draft = {
...draft,
revision: draft.revision + 1,
status: update.status,
canvas: update.canvas,
canvas: protectedCanvas,
generations: update.generations,
updatedAt: draft.updatedAt + 1,
};
return { status: 'ok' as const, value: draft };
},
);
const acknowledgeCandidateLayers = vi.fn(
async (
acknowledgement: Parameters<
ImageCanvasProjectPort['acknowledgeCandidateLayers']
>[0],
) => {
await input?.candidateAcknowledgementGate?.promise;
if (!draft) throw new Error('draft missing');
persistenceOperations.push({
kind: 'acknowledge',
layerIds: [...acknowledgement.layerIds],
});
for (const layerId of acknowledgement.layerIds) {
if (draft.canvas.layers.some((layer) => layer.layerId === layerId)) {
unacknowledgedCandidateLayerIds.delete(layerId);
}
}
return { status: 'ok' as const, value: draft };
},
);
const settleGenerationFailure = vi.fn(
async (
failureInput: Parameters<
@@ -399,6 +442,7 @@ function memoryHost(input?: {
return { status: 'ok', value: draft };
},
updateDraft,
acknowledgeCandidateLayers,
discardDraft,
},
asset: {
@@ -566,6 +610,7 @@ function memoryHost(input?: {
flipX: false,
flipY: false,
};
unacknowledgedCandidateLayerIds.add(candidateLayer.layerId);
draft = {
...draft,
revision: draft.revision + 1,
@@ -738,6 +783,7 @@ function memoryHost(input?: {
selectedCandidateCommits,
generationCalls,
updates,
persistenceOperations,
revokedSubscriptions,
nativeImportCalls,
loadDraft,
@@ -745,6 +791,7 @@ function memoryHost(input?: {
recoverImages,
settleGenerationFailure,
updateDraft,
acknowledgeCandidateLayers,
confirmGenerationServiceIdentity,
discardDraft,
getDraft: () => draft,
@@ -3241,6 +3288,188 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByText('已设为最终图,精修草稿可继续编辑')).toBeTruthy();
expect(onCancel).not.toHaveBeenCalled();
});
it('候选确认形成导入屏障,确认后删除并再次保存不会被宿主保护逻辑复活', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
layerId: 'source-layer-for-candidate-ack',
resourceId: 'local-asset:source-asset',
title: '候选确认入口图',
mediaRef: { kind: 'project-asset' as const, assetId: 'source-asset' },
};
const candidateAcknowledgementGate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer],
}),
candidateAcknowledgementGate,
});
renderSurface(memory.host, refineScope, vi.fn(), vi.fn(), vi.fn(), {
name: '候选确认素材',
kind: 'illustration',
});
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 候选确认入口图' }),
);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '生成后确认再删除' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
const candidateButton = await screen.findByRole('button', {
name: '选择图层 候选确认素材 候选图',
});
await waitFor(() =>
expect(memory.acknowledgeCandidateLayers).toHaveBeenCalledTimes(1),
);
const candidateLayerId =
memory.acknowledgeCandidateLayers.mock.calls[0]?.[0].layerIds[0];
expect(candidateLayerId).toMatch(/^generated-layer-/);
fireEvent.click(screen.getByRole('button', { name: '导入图片' }));
await act(async () => {
await Promise.resolve();
});
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(0);
await act(async () => {
candidateAcknowledgementGate.resolve();
await candidateAcknowledgementGate.promise;
});
await waitFor(() =>
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(1),
);
const candidateSaveIndex = memory.persistenceOperations.findIndex(
(operation) =>
operation.kind === 'update' &&
operation.canvas.layers.some(
(layer) => layer.layerId === candidateLayerId,
),
);
expect(candidateSaveIndex).toBeGreaterThanOrEqual(0);
expect(
memory.persistenceOperations.findIndex(
(operation) =>
operation.kind === 'acknowledge' &&
operation.layerIds.includes(candidateLayerId!),
),
).toBeGreaterThan(candidateSaveIndex);
fireEvent.click(
await screen.findByRole('button', {
name: '选择图层 候选确认素材 候选图',
}),
);
const candidateQuickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const savesBeforeDelete = memory.updateDraft.mock.calls.length;
fireEvent.click(
within(candidateQuickEdit).getByRole('button', { name: '删除' }),
);
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
savesBeforeDelete,
),
);
expect(
memory
.getDraft()
?.canvas.layers.some((layer) => layer.layerId === candidateLayerId),
).toBe(false);
});
it('重新打开 candidate-ready 草稿时先幂等确认,后续删除不会复活', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const candidateLayer = {
...keyboardCanvas().layers[0],
layerId: 'recovered-candidate-layer',
resourceId: 'draft-media:recovered-candidate',
title: '恢复候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'recovered-candidate',
mediaType: 'image/png' as const,
sha256: 'd'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
};
const initialDraft = {
...draftFixture(refineScope, {
...emptyCanvas(),
layers: [candidateLayer],
selectedLayerIds: [candidateLayer.layerId],
primarySelectedLayerId: candidateLayer.layerId,
}),
generations: [
{
generationId: 'recovered-generation',
intentId: 'recovered-intent',
phase: 'candidate-ready' as const,
referenceResourceIds: [],
outputAssetId: null,
sourceLayerId: null,
placeholder: null,
errorCode: null,
createdAt: 1,
updatedAt: 2,
},
],
};
const memory = memoryHost({
initialDraft,
initialUnacknowledgedCandidateLayerIds: [candidateLayer.layerId],
});
renderSurface(memory.host, refineScope);
await waitFor(() =>
expect(memory.acknowledgeCandidateLayers).toHaveBeenCalledWith({
scope: refineScope,
layerIds: [candidateLayer.layerId],
}),
);
const candidateButton = await screen.findByRole('button', {
name: '选择图层 恢复候选图',
});
await waitFor(() =>
expect((candidateButton as HTMLButtonElement).disabled).toBe(false),
);
fireEvent.click(candidateButton);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const savesBeforeDelete = memory.updateDraft.mock.calls.length;
fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' }));
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
savesBeforeDelete,
),
);
expect(
memory
.getDraft()
?.canvas.layers.some(
(layer) => layer.layerId === candidateLayer.layerId,
),
).toBe(false);
});
it('精修图片直接修改时保留源资源引用、来源图层和生成占位', async () => {
const refineScope: ImageCanvasHostScope = {
projectId: 'project-refine',
@@ -3725,6 +3954,11 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
manifest,
};
}
if (
command === 'acknowledge_local_project_asset_canvas_candidate_layers'
) {
return { draft: draftFixture(scope) };
}
throw new Error(`unexpected command: ${command}`);
},
);
@@ -3765,6 +3999,23 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(commitRequests).toHaveLength(2);
expect(commitRequests[0]?.[1]).toEqual(commitRequests[1]?.[1]);
const acknowledgement = await adapter.project.acknowledgeCandidateLayers({
scope,
layerIds: ['candidate-layer'],
});
expect(acknowledgement.status).toBe('ok');
expect(invokeSpy).toHaveBeenCalledWith(
'acknowledge_local_project_asset_canvas_candidate_layers',
expect.objectContaining({
input: expect.objectContaining({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
draftId: scope.draftId,
layerIds: ['candidate-layer'],
}),
}),
);
const mismatch = await adapter.project.loadDraft({
...scope,
projectId: 'replacement-project',
@@ -3775,7 +4026,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
code: 'project-identity-conflict',
}),
);
expect(invokeSpy).toHaveBeenCalledTimes(3);
expect(invokeSpy).toHaveBeenCalledTimes(4);
const invalidRevision = await adapter.completion.commitImage({
...commitInput,
@@ -3786,7 +4037,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(invalidRevision).toEqual(
expect.objectContaining({ status: 'failed' }),
);
expect(invokeSpy).toHaveBeenCalledTimes(3);
expect(invokeSpy).toHaveBeenCalledTimes(4);
});
it('Tauri adapter 不向生成与恢复命令透传登录 Token 或 External API Key', async () => {
@@ -9,6 +9,7 @@ import {
createEmptyResourceCanvasLayout,
createResourceCanvasCardSizeByResourceId,
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
fitResourceCanvasViewportToContent,
moveResourceCanvasPosition,
normalizeInfiniteResourceCanvasViewport,
reconcileResourceCanvasLayout,
@@ -18,6 +19,7 @@ import {
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
type ResourceCanvasCardSize,
resourceCanvasContentBounds,
resourceCanvasImageCardSize,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
@@ -789,6 +791,46 @@ describe('resource canvas variable card geometry', () => {
});
});
it('fits the exact resource card bounds to the reset viewport', () => {
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'negative-card',
section: 'art',
x: -240,
y: -160,
manuallyPlaced: true,
},
{
resourceId: 'positive-card',
section: 'art',
x: 480,
y: 320,
manuallyPlaced: true,
},
];
const bounds = resourceCanvasContentBounds(
positions,
new Map([
['negative-card', { width: 180, height: 128 }],
['positive-card', { width: 220, height: 180 }],
]),
);
expect(bounds).toEqual({
x: -240,
y: -160,
width: 940,
height: 660,
});
const viewport = fitResourceCanvasViewportToContent({
bounds,
canvasSize: { width: 800, height: 600 },
});
expect(viewport.scale).toBeCloseTo(768 / 940, 8);
expect(bounds.width * viewport.scale).toBeCloseTo(768, 8);
expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8);
});
it('accepts card sizes directly on resource items for hook integration', () => {
const wide = {
...resource('wide', 'art', 0),
@@ -257,7 +257,7 @@ type ProjectResourceCanvasLayout = {
- `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为整数;坐标不使用 viewport、页面或资源详情浮层坐标系。
- `updatedAt` 是持久层生成的 Unix 毫秒时间戳,前端不得自行覆盖。
- `revision``0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`。JSON / Tauri / TypeScript 全链路合法范围固定为 `0..=9_007_199_254_740_991``Number.MAX_SAFE_INTEGER`),读取、返回或提交负数、小数、非有限值与超限整数都必须失败关闭。
- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false``manuallyPlaced=true` 仅用于兼容历史 sidecar 和保留底层合同;当前界面不会因指针操作新增该值
- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`用户在当前栏目画布中拖动资源卡并成功释放后写入 `manuallyPlaced=true`;后续资源投影和 dependency 自动重算必须保留这些手动坐标
- 同一份布局中 `resourceId` 必须唯一。持久层允许暂时存在当前资源投影中没有的旧 ID,因为 Agent 文本成果等资源可能晚于 manifest 恢复;前端协调后必须在下一次成功写入中清除已确认失效的坐标。
- 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB``resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `-1_000_000..=1_000_000`
@@ -317,8 +317,8 @@ type UpdateProjectResourceCanvasLayoutResult =
- dependency 默认布局按 Rust 返回的 `dependencyDepth` 形成横向层级;同层仅在所属固定分区内按精确引用、聚合 task-flow 的稳定邻接、连通簇和固定两轮中位数扫描确定纵向次序,随后寻找第一个不重叠位置。task-flow 只作为聚合超边参与分组和排序,不能生成资源两两边。dependency 自动布局使用专用 `48px` 列间走线区和 `40px` 行间走线区;相关簇以本簇最大层行数为高度,资源较少的层在该高度中居中,避免菱形 / 分叉关系一侧极短、另一侧过长。type 默认布局继续使用原有 `16px` 行列间距,并固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact``attachment``agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和 `-1_000_000..=1_000_000` 坐标范围由前后端同名合同维护。超深依赖仍保留原始 `dependencyDepth` 业务真相,但显示坐标在上限列确定性饱和并纵向避让;自动布局在 IPC 前必须保证全部 `x / y` 合法,不能向 Tauri 永久重放必然失败的坐标。
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的历史坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪、`dependencyDepth` 或资源拓扑身份签名(精确引用端点和聚合 task-flow 成员)变化后按最终拓扑确定性重算。签名以稳定资源 ID 的规范端点 / 成员序列生成固定大小摘要,不使用显示名称或浏览器测量值;自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写或裁切持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
- 分区高度和内容倍率都不属于逻辑布局几何。资源卡始终使用 `x / y``180×128` 逻辑尺寸;内容 plane 只在显示层按 `50%..200%` 变换,并用同比例 frame 形成真实滚动范围。标题栏后的分区 viewport 使用内部滚动暴露缩放后的卡片,整个分区继续参与外层正常文档流。触摸板捏合、`Ctrl/Cmd + wheel` 与缩放按钮共享倍率状态,普通 wheel 不缩放;`Ctrl/Cmd + wheel` 必须由可取消的原生 `{ passive: false }` 监听处理并真实取消 WebView 默认缩放。搜索、项目或 mode 切换均不得把倍率写回布局。分区内部滚动按 `projectId + mode + category` 隔离,外层资源画布按 `projectId + mode` 隔离,二者在详情开关、模式与项目切换后分别恢复
- 窗口尺寸变化只改变当前栏目的可视范围,不回写或裁切持久坐标,也不因资源 extent 或 resize 把已平移的 viewport 拉回内容边界。当前客户端继续以 `1280×800` 横屏合同验收。
- 任一栏目出现资源后,资源管理固定使用 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本` 五栏目分页画布;每个栏目按 `projectId + mode + category` 保留独立 viewport。普通 wheel 切换栏目,`Ctrl/Cmd + wheel` 以指针位置为锚点缩放当前无限画布,空白拖动只平移当前栏目;非空状态不提供分区高度、分区内部滚动或分区内容倍率。搜索和详情开关不得重置 viewport,项目、mode 或栏目切换只恢复各自会话状态,显式复位才重新适配当前栏目内容
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;dependency 模式必须先等待与当前 `projectPath + projectId + resource inputs` 匹配的 Rust 图进入 `ready``failed` 终态,等待期间不得创建 fallback、读取 sidecar、协调资源或入队保存。`failed` 只允许以空图降级初始化一次。项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。当前 scope 内资源自动协调写入使用单写者 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision。切换项目或 mode 后,旧 scope 的在途请求不能阻塞新 scope 队列;前端放弃旧请求槽位并丢弃其迟到响应,后端继续依靠 `expectedProjectId + expectedRevision + 系统锁` 仲裁已发出的请求。
- 自动协调 CAS 冲突时直接载入返回的最新布局;仍需协调时可以基于权威 revision 最多追加 `2` 次重试,持续跨窗口写入时不得无限自旋。当前提示只说明“布局已在其他窗口更新”,不得要求用户重新拖动。
@@ -328,7 +328,7 @@ type UpdateProjectResourceCanvasLayoutResult =
- Pointer Events 使用 `5px` 移动阈值,拖动期间指针捕获到卡片,画布普通滚轮仍切栏目,`Ctrl/Cmd + wheel` 仍以真实画布指针位置缩放。
- 拖动提交沿用现有单写者 FIFO 与 CAS 冲突重试;冲突提示沿用“布局已在其他窗口更新”,用户可重新拖动。历史 `manuallyPlaced=true` 坐标仍优先保留。
- 已有 Hook 手动意图、命令式 SVG preview、sidecar 字段和 Rust CAS 基础设施可以保留为未接线技术资产;当前资源卡不得调用这些入口
- 资源卡拖动统一调用现役 Hook 手动意图、命令式 SVG preview、sidecar 字段和 Rust CAS:拖动期间只更新当前会话预览,成功释放后提交一次手动布局 CAS,取消或未超过阈值不写入
#### 5.2.6 资源依赖关系图层
@@ -526,14 +526,14 @@ type ProjectAgentMudPointAttribution = {
5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;存储边界以跨进程专用锁串行覆盖旧状态读取、前缀校验、安装和回读,修改、删除、重排或并发旧快照覆盖已有版本时写入失败。
6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revisiondependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。
### 7.5 资源分区独立高度验收
### 7.5 资源栏目分页无限画布验收
1. 四个分区均可独立缩小、放大和恢复默认高度;任一分区变化时其它分区高度、资源卡尺寸和媒体比例不变。到达上下限时对应按钮同时具备正确的 disabled 视觉与辅助技术语义
2. 历史分区高度、倍率和内部滚动状态按 `projectId + dependency|type + document|art|audio|code|version` 隔离,外层资源画布位置按 `projectId + dependency|type` 隔离;切换模式、切换项目和打开 / 关闭资源详情后,分别恢复当前会话中的内外滚动位置,且键盘焦点不被无关重置
3. 最小高度可操作标题控制并完整容纳至少一排卡片;最大高度不超过中央资源画布当前可用高度。窗口变小后超界尺寸被永久夹取到新上限,后续放大窗口不自动恢复旧超界值
4. 分区放大只在正常文档流中下推后续分区,不使用浮层、绝对定位、负 margin 或 z-index 覆盖。分区内容超出时内部可滚动,外层画布仍可滚动访问其它分区,`1280×800` 无页面级溢出
5. dependency 线在高度、分区内部滚动、外层滚动和窗口 resize 后仍与可见卡片端点对齐;单端卡片被分区 viewport 裁掉时只保留边界继续线,不保留悬空主路径,两端都被裁掉时隐藏,线段不穿过标题栏。滚动与 resize 在每个分区内使用单一 `requestAnimationFrame` 合帧,每个分区 plane 最多创建一个 `ResizeObserver` 并在卸载时清理全部监听
6. 高度操作不调用 `update_local_project_resource_canvas_layout`,不改 manifest、不更新 sidecar revision;阶段一的图片 / 视频 / 音频 / 文档 / 版本卡片、单媒体播放与中央详情回归全部通过。
1. 完全空项目继续显示全部栏目的分区展览;任一栏目出现资源后,dependency / type 都切换为固定五栏目分页画布,悬浮 Dock、底部下一页标题和普通 wheel 可访问全部栏目,空栏目也可打开空画布
2. 每个 `projectId + dependency|type + document|art|audio|code|version` 组合保留独立 viewport;切换栏目、模式、项目和打开 / 关闭详情后恢复对应平移与缩放,窗口 resize、媒体测量和资源 extent 变化不得重置用户 viewport
3. 当前栏目允许空白拖动无限平移;`Ctrl/Cmd + wheel` 以指针为锚点缩放,显式复位按包含负坐标资源在内的完整 bounds 适配内容。非空状态不显示分区高度、分区内部滚动或分区内容倍率操作
4. 资源卡超过 `5px` 阈值后进入拖动,预览和 dependency 线同步移动;成功释放只提交一次 `manuallyPlaced=true` CAS,取消、移出释放、媒体控制点击和未超过阈值均不写布局
5. dependency 引导线消费当前栏目的同类型精确引用,并与卡片共享同一 viewport transform;平移、缩放、拖动预览、搜索和 resize 后端点保持对齐,type 模式不渲染引导线
6. 栏目分页、viewport 和资源卡拖动只修改工作台会话状态或资源布局 sidecar,不改 manifest、项目 mutation revision、Runtime verification、Agent 权限和预览状态;图片、视频、音频、文档、代码、版本卡片及非模态详情回归全部通过。
### 7.6 阶段七完整验收
@@ -14413,8 +14413,8 @@
- 资源管理固定展示 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本` 五个栏目;空栏目仍可从 Dock 打开空画布。资源卡、Dock、复位和下一页按钮上的普通滚轮继续切页,Ctrl/Meta + 滚轮继续缩放;只有显式标记为原生滚动区域的控件隔离画布 wheel。资源详情为非模态浮层,打开时背景画布 listener 保持可用。
- 资源卡在 pointerdown 即建立 capture,栏目切换、滚轮切页、排序切换和卸载统一取消拖拽并清理预览;媒体播放等交互控件不得启动坐标写入。导航 extent 按全部卡片计算 `minX/minY/maxX/maxY`,负坐标必须进入 fit bounds 并把非零原点传给 viewport 约束。
- 图片生成成功时先持久化并回读候选媒体、候选图层和公开 generation,再发布私有 `candidate-ready`。前端不得用生成返回的整份草稿 hydrate 覆盖生成期间的本地编辑;只合并候选图层和 generation 权威事实,并排在已有保存队列之后用最新本地 layers、viewport、selection 和 background 完成确认保存。
- `ImageCanvasAssetPort.importLocalImages``ImageCanvasGenerationPort.archiveFailedGeneration` 是必选 Host Port;不支持的宿主必须返回结构化 `unsupported-capability`,共享 UI 不以方法缺失推断能力。快速编辑卡使用实测尺寸在图层上下方自动翻转,并钳制到 viewport 四边。
- 图片生成成功时先持久化并回读候选媒体、候选图层和公开 generation,再发布私有 `candidate-ready`。前端不得用生成返回的整份草稿 hydrate 覆盖生成期间的本地编辑;只合并候选图层和 generation 权威事实,并排在已有保存队列之后用最新本地 layers、viewport、selection 和 background 保存草稿,再在同一 FIFO 内执行独立幂等候选确认。提交、导入、生成、归档和放弃草稿等 revision-sensitive 操作必须先等待该确认屏障;重新打开含 `candidate-ready` 的权威草稿时,在开放编辑前对当前图层 ID 执行幂等确认,以覆盖候选落盘后、前端确认前退出的窗口
- `ImageCanvasProjectPort.acknowledgeCandidateLayers``ImageCanvasAssetPort.importLocalImages``ImageCanvasGenerationPort.archiveFailedGeneration` 是必选 Host Port;不支持的宿主必须返回结构化 `unsupported-capability`,共享 UI 不以方法缺失推断能力。快速编辑卡使用实测尺寸在图层上下方自动翻转,并钳制到 viewport 四边。
## 2026-08-23 AGC 素材画布失败结算使用可重放中间态
@@ -14464,3 +14464,11 @@
- 决策:将 External v1 `/api/external/v1/editor/images/background-removals` 通过 `agc_remove_background` 加入受控 `agc_tools`。工具只接受当前 manifest 的图片 `sourceLocalAssetId` 与结果名称;客户端负责正式 resourceId、画布/素材目录、稳定 operation/idempotency 身份、权限和错误脱敏,不向 Codex 暴露内部 BgFilter worker、凭据或任意 API。
- 约束:异步结果只投影有界队列状态,不允许模型自行构造源 URL 或在不确定提交后更换请求身份;External v1 负责 API Key、幂等接收与统一 operation 查询,客户端不得绕过该契约。
## 2026-08-24 资源详情动作、空态滚动与最终图多步恢复
- 角色资源详情的“生成动画”和“编辑资源”使用同一显式动作样式类,不再以 `first-child` 决定哪个业务按钮获得样式。
- 资源总览只有在至少存在一个资源、进入栏目分页画布时才挂载 paged 与 dependency 交互壳;完全空项目保留五分区展览和纵向滚动。
- viewport 按“排序模式 + 栏目”隔离保存;在“按依赖 / 按类型”之间来回切换,或离开资源管理进入运行视图后返回时,必须恢复对应组合的平移与缩放,不得通过自动点击复位或复用另一模式的 viewport 覆盖用户视角。只有该组合首次获得可测量容器尺寸或用户显式点击复位时才重新适配内容。
- 资源画布的导航范围与复位适配范围分离:导航范围继续保留最小世界尺寸和负坐标可达性;复位只按当前栏目资源卡真实包围盒计算,使用 `16px` 留白并允许在共享上限内放大,使至少一个轴贴合可用视口。
- 最终图恢复的 supersede 证明按相邻 transaction 的 after/before manifest 与 project revision 快照逐笔遍历,直到精确到达当前状态。三次及以上连续提交的早期事务不再因缺少“直连当前事务”而误报对账;任一中间账本、快照、正式文件或当前资产身份不完整时仍失败关闭。
@@ -50,13 +50,14 @@
- `recover_asset_canvas_transaction_locked``ledger=Committed` 分支要求当前 manifest 中该 asset 仍指向本事务的 `finalImageRelativePath`;后续提交已把同一 asset 指向新的 `assets/canvas/<name>--<commitId>.png``asset_present=false` 后直接 `mark_asset_canvas_reconciliation_locked`,没有先执行 `find_superseding_asset_canvas_commit`(该检查目前只在 unresolved 分支生效)。
- 前端 `recover()` 只要存在任一 `reconciliation-required` outcome 就把整次恢复判失败,因此一个已被后续提交取代的旧事务会阻塞整个画布打开。
- 现有测试此前只覆盖“第一个事务被强制标记 unresolved 后由后续已提交事务取代”,缺少“两个事务都成功提交后恢复”的用例,因此未暴露。
- 单步 supersede 修复只接受旧事务的 after 快照与当前事务的 before 快照直接衔接。连续提交三次及以上时,最早事务必须跨过一个或多个已提交中间事务才能到达当前 manifest/revision;把“不是直接前驱”当作无法证明仍会误报对账。
修复合同:
1. `ledger=Committed` 分支在进入 reconciliation 前先执行同一套 supersede 证明;命中则把旧 journal/ledger 标记为 `Superseded`,返回 `Superseded`,不得阻塞画布打开。
2. supersede 证明继续要求后续事务已完整提交、after manifest/revision 与当前状态一致、正式文件哈希匹配,且后续事务的 before 快照精确衔接事务的 after 快照;不得仅凭时间戳或路径变化猜测。
2. supersede 证明继续要求每个后续事务已完整提交、正式文件哈希匹配,且相邻事务的 before 快照精确衔接前一事务的 after 快照;允许沿一条逐笔验证的提交链到达当前 manifest/revision,不得跳过中间事务或仅凭时间戳、revision 数值、路径变化猜测。
3. 重复恢复幂等:已 `Superseded` 的事务直接返回既有状态,不再改成对账。
4. 回归测试覆盖“两连续成功提交后恢复”:旧事务必须收敛为 `Superseded`事务为 `AlreadyCommitted`,整轮恢复不得出现 `ReconciliationRequired`
4. 回归测试覆盖“两连续成功提交”和“三次连续提交且最早事务已进入对账态”后恢复:所有旧事务必须收敛为 `Superseded`当前事务为 `AlreadyCommitted`,整轮及重复恢复不得出现 `ReconciliationRequired`
5. 恢复失败可观测性仍保留后续增强方向:失败结果应携带 outcomes 明细(commitId、status、stage、证据摘要),前端展示具体事务并支持重试。
验收:
@@ -83,14 +84,16 @@
- 栏目顺序固定为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲以左侧垂直居中的悬浮 Dock 展示全部栏目。常态缩小、降低不透明度并移除容器与选中项背景,只露出栏目文字;悬停或键盘聚焦时平滑恢复完整尺寸,显示栏目图标、Dock 背景和选中态视觉强调。默认停留在该顺序中的第一个非空栏目,空栏目仍可打开空画布。
- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;持续滚动时将离散切页意图加入有界队列,浏览器合并形成的单个大幅滚轮事件也要按输入强度拆分为多个切页意图。同一节流窗口内的待处理步数必须合并为一次目标栏目切换,不能逐页挂载并加载中间栏目的资源,以免资源渲染阻塞后续滚轮输入;同时限制切页频率和最长排队距离,避免触控板惯性长时间自动翻页。点击大纲、底部“下一页”标题或自动定位资源属于显式切页,必须先取消尚未执行的滚轮队列,不能在显式切页后继续跳转;开始拖动画布或资源卡时也必须取消待处理切页,切页前必须终止旧栏目的画布拖动和 pointer capture,避免旧 viewport 写入新栏目。
- 每个栏目画布保留独立 viewport;切换栏目后先按该栏目内容适配视口。空白处拖拽平移画布,资源卡拖拽移动卡片并更新依赖线,Ctrl/Meta 缩放只作用于当前栏目,不能牵动其它栏目。
-种排序模式下的每个栏目画布保留独立 viewport首次进入该“排序模式 + 栏目”组合时按当前内容适配视口,离开后再返回则恢复该组合上次的平移和缩放。空白处拖拽平移画布,资源卡拖拽移动卡片并更新依赖线,Ctrl/Meta 缩放只作用于当前组合,不能牵动其它排序模式或栏目。
- 依赖画布复用 `@genarrative/image-canvas-core` 的 viewport 计算,并复用现有资源卡片、布局和依赖连线模型。
- 非空状态不使用资源分区滚动条、分区缩放或分区高度操作作为主要导航;栏目通过大纲、底部下一页标题和滚轮切换。
- 当前栏目画布背景是无限的:用户可以将 viewport 沿 x/y 任意方向平移,画布不以资源 extent 作为导航边界,也不显示可见画布边缘。资源卡片的持久化布局坐标允许落在 `-1_000_000..=1_000_000`,用于支撑元素位于世界原点左上方;超出该范围仍拒绝写入,避免持久化非法布局。这与 viewport 能否继续平移是两层独立语义。搜索、详情卡和临时隐藏不得改变 viewport。
- 普通滚轮切换栏目,指针拖动空白平移画布,Ctrl/Meta 缩放、复位以及容器 resize 后都必须保持同一套 viewport 数据流。切换栏目、切换排序模式或显式复位时才重新适配内容;图片尺寸测量、布局拖动或资源 extent 变化只归一化当前 viewport,不得意外重置用户已经完成的平移和缩放。普通平移不夹取 x/y;缩放仍受共享画布的最小/最大比例限制初次 fit 只使用 `96px` 内缩作为视觉留白
- 普通滚轮切换栏目,指针拖动空白平移画布,Ctrl/Meta 缩放、复位以及容器 resize 后都必须保持同一套 viewport 数据流。只有“排序模式 + 栏目”组合首次获得可测量容器尺寸或用户显式复位时才重新适配内容;返回已访问组合、图片尺寸测量、布局拖动或资源 extent 变化只归一化并保留该组合的当前 viewport,不得意外重置用户已经完成的平移和缩放。普通平移不夹取 x/y;缩放仍受共享画布的最小/最大比例限制初次 fit 与显式复位只使用资源卡真实包围盒,不把导航最小尺寸、原点空区或额外布局 gap 算入,并以 `16px` 紧凑留白在共享缩放上限内尽量铺满视口
- 资源卡拖动使用 `5px` 阈值区分点击与移动;移动期间按当前 scale 乐观换算世界坐标、显示拖动态并同步依赖线,释放时提交一次 `manuallyPlaced=true` 布局 CAS,取消则回滚预览且不提交。拖动后的释放点击不打开详情。
- 依赖模式只在当前栏目画布内显示两端都属于该栏目的合法精确引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。
- 资源详情卡包含元数据、媒体预览和“编辑资源”操作,但不使用全屏 backdrop、不声明 `aria-modal=true`、不把 `focusedResource` 作为背景工具栏渲染条件。桌面端允许继续操作背景画板;窄屏可以使用有边界的贴边卡,但背景组件必须保持挂载。
- 资源详情卡包含元数据、媒体预览和“编辑资源”操作,但不使用全屏 backdrop、不声明 `aria-modal=true`、不把 `focusedResource` 作为背景工具栏渲染条件。角色资源同时显示“生成动画”时,两个业务操作按钮必须使用一致样式,不能依赖 DOM 中的首按钮位置。桌面端允许继续操作背景画板;窄屏可以使用有边界的贴边卡,但背景组件必须保持挂载。
- 完全空项目继续使用可纵向滚动的五分区展览,不挂载分页画布或依赖画布的 `overflow: hidden` 交互壳;低高度窗口在两种排序模式下都能滚动到“项目版本”。
- 在“按依赖 / 按类型”之间切换或离开资源管理进入运行视图后返回时,恢复对应“排序模式 + 栏目”的现有 viewport,不自动触发“复位资源画布”,也不得用另一排序模式的 viewport 覆盖用户已经完成的平移和缩放。首次 fit 与用户显式复位仍使用同一套真实资源包围盒算法。
- 资源详情卡定位相对 `.game-workbench-stage`,桌面端在中间主视窗居中并受主视窗宽高边界约束;禁止使用相对整个窗口的右上角 `position: fixed` 定位。
- 图片选中后的浮动工具栏整体退役。普通点击图片直接打开唯一的图片下方快速编辑卡;拖动不误触发,点击另一张图片切换卡片,点击画布空白关闭卡片。
- 快速编辑卡底部统一承载“删除”“设为最终图”“修改”。“删除”只删除当前绑定图层并进入画布 history;“设为最终图”只对完整候选媒体可用;“修改”创建新 generation,不覆盖来源图层。
+1 -1
View File
@@ -199,7 +199,7 @@ export interface ImageCanvasProjectPort {
canvas: ImageCanvasDraft['canvas'];
generations: ImageCanvasGenerationRecord[];
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
acknowledgeCandidateLayers?(input: {
acknowledgeCandidateLayers(input: {
scope: ImageCanvasHostScope;
layerIds: string[];
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;