修复资源分页与图片生成事务
Project CI / Repository checks (pull_request) Failing after 3m4s
Project CI / Frontend tests (pull_request) Successful in 5m15s
Project CI / Backend tests (pull_request) Successful in 5m19s
Project CI / Native shell tests (pull_request) Successful in 15m46s

修复资源画布滚轮、拖拽取消、负坐标与快速编辑卡边界

修复生成占位、候选持久化、归档恢复与并发保存顺序

收紧 Host Port 合同并补齐回归测试

统一栏目合同并更新权威文档与项目记忆
This commit is contained in:
2026-08-23 20:47:10 +08:00
parent c97130f093
commit c01dc006f9
12 changed files with 749 additions and 164 deletions
@@ -184,6 +184,7 @@ enum GenerationLedgerPhase {
CandidateReady,
AssetDurableCommitted,
Failed,
Archiving,
Archived,
ReconciliationRequired,
}
@@ -201,6 +202,7 @@ impl GenerationLedgerPhase {
Self::CandidateReady => "candidate-ready",
Self::AssetDurableCommitted => "asset-durable-committed",
Self::Failed => "failed",
Self::Archiving => "archiving",
Self::Archived => "archived",
Self::ReconciliationRequired => "reconciliation-required",
}
@@ -855,7 +857,7 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option<AssetCanvasGenerationSt
Some(AssetCanvasGenerationStatus::AssetDurableCommitted)
}
GenerationLedgerPhase::Failed => Some(AssetCanvasGenerationStatus::Failed),
GenerationLedgerPhase::Archived => None,
GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived => None,
GenerationLedgerPhase::ReconciliationRequired => {
Some(AssetCanvasGenerationStatus::ReconciliationRequired)
}
@@ -979,11 +981,11 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<()
}
}
match (&ledger.phase, ledger.archived_at) {
(GenerationLedgerPhase::Archived, Some(archived_at)) => {
(GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived, Some(archived_at)) => {
validate_safe_revision(archived_at, "archivedAt")?;
}
(GenerationLedgerPhase::Archived, None) => {
return Err("归档的素材画布生成账本缺少 archivedAt".to_string());
(GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived, None) => {
return Err("归档的素材画布生成账本缺少 archivedAt".to_string());
}
(_, Some(_)) => {
return Err("未归档的素材画布生成账本不能包含 archivedAt".to_string());
@@ -2982,7 +2984,10 @@ async fn reconcile_generation(
return committed_execution_from_private_result(root, &ledger);
}
migrate_retryable_credential_failure(root, &mut ledger)?;
if ledger.phase == GenerationLedgerPhase::Archived {
if matches!(
ledger.phase,
GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived
) {
return Err("素材画布失败生成已归档".to_string());
}
if ledger.phase == GenerationLedgerPhase::Failed {
@@ -3358,13 +3363,30 @@ async fn reconcile_generation(
"candidate-reconciliation-required",
));
}
let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?;
let persisted = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
.ok_or_else(|| "候选写入后素材画布草稿不存在".to_string())?;
let candidate_layer_id = ledger
.candidate_layer_id
.as_deref()
.ok_or_else(|| "候选写入后生成账本缺少图层身份".to_string())?;
if !persisted.generations.iter().any(|record| {
record.generation_id == ledger.generation_id
&& record.phase == AssetCanvasGenerationStatus::CandidateReady
}) || !persisted
.canvas
.layers
.iter()
.any(|layer| layer.layer_id == candidate_layer_id)
{
return Err("候选写入后回读校验失败".to_string());
}
set_private_phase(
root,
&mut ledger,
GenerationLedgerPhase::CandidateReady,
None,
)?;
let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?;
emit(generation_progress_event(
&ledger,
public_phase_name(&generation.phase),
@@ -3456,8 +3478,16 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
return Err("待归档的素材画布生成账本身份无效".to_string());
}
finish_generation_archive_at(root, &mut ledger, Some(input.expected_draft_revision))
}
fn finish_generation_archive_at(
root: &Path,
ledger: &mut AssetCanvasGenerationLedger,
expected_draft_revision: Option<u64>,
) -> Result<ArchiveAssetCanvasGenerationResult, String> {
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?
let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
.ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?;
if draft.project_id != ledger.project_id
|| draft.draft_id != ledger.draft_id
@@ -3472,7 +3502,7 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
.position(|record| record.generation_id == ledger.generation_id);
if ledger.phase == GenerationLedgerPhase::Archived && generation_index.is_none() {
return Ok(ArchiveAssetCanvasGenerationResult {
generation_id: ledger.generation_id,
generation_id: ledger.generation_id.clone(),
phase: GenerationLedgerPhase::Archived.as_str().to_string(),
archived_at: ledger
.archived_at
@@ -3480,25 +3510,36 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
draft,
});
}
if draft.revision != input.expected_draft_revision {
if generation_index.is_some()
&& expected_draft_revision.is_some_and(|revision| draft.revision != revision)
{
return Err("draft-revision-conflict".to_string());
}
let generation_index =
generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?;
let public_generation = &draft.generations[generation_index];
if public_generation.intent_id != ledger.intent_id {
return Err("待归档的素材画布公开生成记录身份无效".to_string());
}
match ledger.phase {
GenerationLedgerPhase::Failed => {
let generation_index = generation_index
.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?;
let public_generation = &draft.generations[generation_index];
if public_generation.intent_id != ledger.intent_id {
return Err("待归档的素材画布公开生成记录身份无效".to_string());
}
if public_generation.phase != AssetCanvasGenerationStatus::Failed {
return Err("只有公开状态明确失败的素材画布生成可以归档".to_string());
}
ledger.phase = GenerationLedgerPhase::Archived;
ledger.phase = GenerationLedgerPhase::Archiving;
ledger.archived_at = Some(asset_canvas_now());
write_generation_ledger(root, &mut ledger)?;
write_generation_ledger(root, ledger)?;
}
GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived => {
if let Some(generation_index) = generation_index {
let public_generation = &draft.generations[generation_index];
if public_generation.intent_id != ledger.intent_id
|| public_generation.phase != AssetCanvasGenerationStatus::Failed
{
return Err("待归档的素材画布公开生成记录身份无效".to_string());
}
}
}
GenerationLedgerPhase::Archived => {}
GenerationLedgerPhase::ReconciliationRequired => {
return Err("reconciliation-required: 结果未知的素材画布生成不能归档".to_string());
}
@@ -3507,17 +3548,23 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
let archived_at = ledger
.archived_at
.ok_or_else(|| "归档的素材画布生成账本缺少 archivedAt".to_string())?;
draft.generations.remove(generation_index);
draft.revision = draft
.revision
.checked_add(1)
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.updated_at = asset_canvas_now();
write_asset_canvas_draft_locked(root, &draft)?;
.ok_or_else(|| "归档的素材画布生成账本缺少 archivedAt".to_string())?;
if let Some(generation_index) = generation_index {
draft.generations.remove(generation_index);
draft.revision = draft
.revision
.checked_add(1)
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.updated_at = asset_canvas_now();
write_asset_canvas_draft_locked(root, &draft)?;
}
if ledger.phase != GenerationLedgerPhase::Archived {
ledger.phase = GenerationLedgerPhase::Archived;
write_generation_ledger(root, ledger)?;
}
Ok(ArchiveAssetCanvasGenerationResult {
generation_id: ledger.generation_id,
generation_id: ledger.generation_id.clone(),
phase: ledger.phase.as_str().to_string(),
archived_at,
draft,
@@ -3572,6 +3619,30 @@ pub(crate) async fn recover_asset_canvas_generations_at(
events: Vec::new(),
});
}
for generation_id in &generation_ids {
let Some(ledger) = read_generation_ledger(root, generation_id)? else {
continue;
};
if ledger.project_id != input.expected_project_id
|| ledger.draft_id != input.draft_id
|| !matches!(
ledger.phase,
GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived
)
{
continue;
}
let _guard = generation_singleflight_lock(&input.expected_project_id, generation_id).await;
let Some(mut current) = read_generation_ledger(root, generation_id)? else {
continue;
};
if matches!(
current.phase,
GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived
) {
finish_generation_archive_at(root, &mut current, None)?;
}
}
let draft_status = {
let _lock = acquire_asset_canvas_draft_lock(root)?;
read_asset_canvas_draft_locked(root, &input.expected_project_id, &input.draft_id)?
@@ -3599,6 +3670,7 @@ pub(crate) async fn recover_asset_canvas_generations_at(
ledger.phase,
GenerationLedgerPhase::CandidateReady
| GenerationLedgerPhase::AssetDurableCommitted
| GenerationLedgerPhase::Archiving
| GenerationLedgerPhase::Archived
)
&& (ledger.phase != GenerationLedgerPhase::Failed
@@ -3635,6 +3707,7 @@ pub(crate) async fn recover_asset_canvas_generations_at(
GenerationLedgerPhase::CandidateReady
| GenerationLedgerPhase::AssetDurableCommitted
| GenerationLedgerPhase::Failed
| GenerationLedgerPhase::Archiving
| GenerationLedgerPhase::Archived
)
{
@@ -4178,13 +4251,34 @@ mod tests {
set_private_phase(
directory.path(),
&mut ledger,
GenerationLedgerPhase::CandidateReady,
GenerationLedgerPhase::MediaDownloaded,
None,
)
.expect("persist candidate-ready placement ledger");
.expect("persist media-downloaded placement ledger");
let (record, updated, _) = persist_generation_candidate_at(directory.path(), &mut ledger)
.expect("persist candidate at frozen placeholder");
assert_eq!(ledger.phase, GenerationLedgerPhase::MediaDownloaded);
let mut replayed_ledger = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read replayable candidate ledger")
.expect("replayable candidate ledger exists");
assert_eq!(
replayed_ledger.phase,
GenerationLedgerPhase::MediaDownloaded
);
let (_, replayed, _) =
persist_generation_candidate_at(directory.path(), &mut replayed_ledger)
.expect("replay candidate persistence after restart");
assert_eq!(replayed.canvas.layers.len(), updated.canvas.layers.len());
assert_eq!(replayed.generations.len(), updated.generations.len());
set_private_phase(
directory.path(),
&mut replayed_ledger,
GenerationLedgerPhase::CandidateReady,
None,
)
.expect("publish candidate-ready only after candidate readback");
ledger = replayed_ledger;
assert_eq!(
record.source_layer_id.as_deref(),
Some(source_layer_id.as_str())
@@ -4852,6 +4946,63 @@ mod tests {
.any(|record| record.generation_id == input.generation_id));
}
#[tokio::test]
async fn recovery_finishes_an_archive_interrupted_before_public_draft_removal() {
let project_id = "asset-canvas-interrupted-archive-project";
let (directory, draft) = create_generation_fixture(project_id, "失败生成中断归档恢复测试");
let input = generation_input(
directory.path(),
project_id,
&draft,
"创建一个归档中断的测试图片",
);
let mut ledger =
validate_and_prepare_ledger(directory.path(), &input).expect("prepare failed ledger");
mark_generation_error(
directory.path(),
&mut ledger,
false,
"generation-rejected",
&mut |_| {},
)
.expect("persist failed generation");
let failed_draft =
read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id)
.expect("read failed draft")
.expect("failed draft exists");
ledger.phase = GenerationLedgerPhase::Archiving;
ledger.archived_at = Some(asset_canvas_now());
write_generation_ledger(directory.path(), &mut ledger)
.expect("persist interrupted archiving ledger");
let recovery = recover_asset_canvas_generations_at(
directory.path(),
&RecoverAssetCanvasGenerationsInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
},
|_| {},
)
.await
.expect("recovery finalizes interrupted archive without credentials");
assert!(recovery.result.resumed_generation_ids.is_empty());
let archived = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read recovered archive ledger")
.expect("archive ledger remains");
assert_eq!(archived.phase, GenerationLedgerPhase::Archived);
let recovered_draft =
read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id)
.expect("read recovered archive draft")
.expect("recovered archive draft exists");
assert_eq!(recovered_draft.revision, failed_draft.revision + 1);
assert!(!recovered_draft
.generations
.iter()
.any(|record| record.generation_id == input.generation_id));
}
#[tokio::test]
async fn generation_archive_rejects_running_and_reconciliation_required_ledgers() {
for (reconciliation, expected_message) in [
@@ -152,6 +152,52 @@ type PendingGenerationIdentity = {
};
const ASSET_CANVAS_WORLD_SIZE = 12_000;
const QUICK_EDIT_PANEL_GAP = 12;
const QUICK_EDIT_PANEL_PADDING = 12;
export function resolveQuickEditPanelPosition(input: {
layer: Pick<CanvasLayer, 'x' | 'y' | 'width' | 'height'>;
viewport: CanvasViewport;
canvasSize: { width: number; height: number };
panelSize: { width: number; height: number };
}) {
const { layer, viewport, canvasSize, panelSize } = input;
const usableWidth = Math.max(0, canvasSize.width - QUICK_EDIT_PANEL_PADDING * 2);
const usableHeight = Math.max(
0,
canvasSize.height - QUICK_EDIT_PANEL_PADDING * 2,
);
const panelWidth = Math.min(Math.max(0, panelSize.width), usableWidth);
const panelHeight = Math.min(Math.max(0, panelSize.height), usableHeight);
const anchorLeft = viewport.x + layer.x * viewport.scale;
const anchorRight = viewport.x + (layer.x + layer.width) * viewport.scale;
const anchorTop = viewport.y + layer.y * viewport.scale;
const anchorBottom =
viewport.y + (layer.y + layer.height) * viewport.scale;
const anchorCenter = (anchorLeft + anchorRight) / 2;
const minCenter = QUICK_EDIT_PANEL_PADDING + panelWidth / 2;
const maxCenter = canvasSize.width - QUICK_EDIT_PANEL_PADDING - panelWidth / 2;
const belowTop = anchorBottom + QUICK_EDIT_PANEL_GAP;
const aboveTop = anchorTop - QUICK_EDIT_PANEL_GAP - panelHeight;
const preferredTop =
belowTop + panelHeight <= canvasSize.height - QUICK_EDIT_PANEL_PADDING
? belowTop
: aboveTop;
return {
left: Math.min(
Math.max(anchorCenter, minCenter),
Math.max(minCenter, maxCenter),
),
top: Math.min(
Math.max(preferredTop, QUICK_EDIT_PANEL_PADDING),
Math.max(
QUICK_EDIT_PANEL_PADDING,
canvasSize.height - QUICK_EDIT_PANEL_PADDING - panelHeight,
),
),
};
}
const GENERATION_ASPECT_RATIOS = ['1:1', '2:3', '3:2', '9:16', '16:9'] as const;
@@ -346,6 +392,34 @@ function runtimeGenerationTaskFromRecord(
};
}
function runtimeLayerFromDraftLayer(
layer: ImageCanvasDraft['canvas']['layers'][number],
previewUrl: string,
): RuntimeCanvasLayer {
return {
id: layer.layerId,
resourceId: layer.resourceId,
resourcePersistenceState: 'self-contained-local',
title: layer.title,
src: previewUrl,
mediaType: 'image',
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
zIndex: layer.zIndex,
sourceType: 'uploaded',
groupId: layer.groupId,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
flipY: layer.flipY,
mediaRef: layer.mediaRef,
};
}
const MODAL_FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
@@ -765,6 +839,11 @@ export function AssetCanvasSurface({
const [recoveryReloadToken, setRecoveryReloadToken] = useState(0);
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
const viewportElementRef = useRef<HTMLDivElement | null>(null);
const quickEditPanelRef = useRef<HTMLElement | null>(null);
const [quickEditPanelSize, setQuickEditPanelSize] = useState({
width: 672,
height: 352,
});
const importInputRef = useRef<HTMLInputElement | null>(null);
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
@@ -1052,28 +1131,7 @@ export function AssetCanvasSurface({
throw new Error('stale-asset-canvas-epoch');
}
previewUrlsRef.current.add(preview.value.previewUrl);
return {
id: layer.layerId,
resourceId: layer.resourceId,
resourcePersistenceState: 'self-contained-local' as const,
title: layer.title,
src: preview.value.previewUrl,
mediaType: 'image' as const,
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
zIndex: layer.zIndex,
sourceType: 'uploaded' as const,
groupId: layer.groupId,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
flipY: layer.flipY,
mediaRef: layer.mediaRef,
} satisfies RuntimeCanvasLayer;
return runtimeLayerFromDraftLayer(layer, preview.value.previewUrl);
}),
);
if (epoch !== epochRef.current) return;
@@ -1399,6 +1457,32 @@ export function AssetCanvasSurface({
return () => observer.disconnect();
}, [draft]);
useLayoutEffect(() => {
if (!quickEditOpen) return undefined;
const element = quickEditPanelRef.current;
if (!element) return undefined;
const update = () => {
const rect = element.getBoundingClientRect();
setQuickEditPanelSize((current) => {
const next = {
width: rect.width || element.offsetWidth || current.width,
height: rect.height || element.offsetHeight || current.height,
};
return current.width === next.width && current.height === next.height
? current
: next;
});
};
update();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}
const observer = new ResizeObserver(update);
observer.observe(element);
return () => observer.disconnect();
}, [quickEditOpen]);
const persistDraft =
useCallback(async (): Promise<ImageCanvasDraft | null> => {
const epoch = epochRef.current;
@@ -1628,7 +1712,6 @@ export function AssetCanvasSurface({
const handleLocalImport = useCallback(async () => {
if (
!host.asset.importLocalImages ||
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current
) {
@@ -1653,6 +1736,12 @@ export function AssetCanvasSurface({
viewportSize: canvasSize,
});
if (epoch !== epochRef.current) return;
if (imported.status === 'unsupported-capability') {
setImportPending(false);
setNotice(imported.message);
importInputRef.current?.click();
return;
}
if (imported.status !== 'ok') {
setNotice(
imported.status === 'conflict'
@@ -2360,30 +2449,6 @@ export function AssetCanvasSurface({
createdAt: Date.now(),
updatedAt: Date.now(),
};
const initialGenerationRecord: ImageCanvasGenerationRecord = {
generationId: identity.generationId,
intentId: identity.intentId,
phase: 'generation-running',
referenceResourceIds: [...generationReferenceResourceIds],
outputAssetId: null,
sourceLayerId: sourceLayer?.id ?? null,
placeholder,
errorCode: null,
createdAt: initialTask.createdAt,
updatedAt: initialTask.updatedAt,
};
const draftWithGeneration = draftRef.current;
if (draftWithGeneration) {
const nextDraft = {
...draftWithGeneration,
generations: [
...draftWithGeneration.generations,
initialGenerationRecord,
],
};
draftRef.current = nextDraft;
setDraft(nextDraft);
}
setGenerationTasksCollapsed(false);
setGenerationTaskTab('active');
setGenerationTasks((current) => [...current, initialTask]);
@@ -2396,7 +2461,8 @@ export function AssetCanvasSurface({
const frozenImageSize = generationImageSize;
const frozenAssetKind = assetKind;
const frozenAssetName = assetName;
const needsDraftPersist = true;
const needsDraftPersist =
documentVersionRef.current !== persistedDocumentVersionRef.current;
setGenerationDialog(null);
setQuickEditOpen(false);
dragRef.current = null;
@@ -2486,13 +2552,39 @@ export function AssetCanvasSurface({
setLifecycle({ kind: 'canvas.editing', dirty: false });
return;
}
result.value.images.forEach((image) =>
URL.revokeObjectURL(image.previewUrl),
applyDraftCandidate(result.value.draft);
const imageByResourceId = new Map(
result.value.images.map((image) => [image.resourceId, image]),
);
await hydrateDraft(result.value.draft, epoch);
// 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.
const candidateLayers = result.value.draft.canvas.layers
.filter((layer) => imageByResourceId.has(layer.resourceId))
.map((layer) => {
const image = imageByResourceId.get(layer.resourceId)!;
previewUrlsRef.current.add(image.previewUrl);
return runtimeLayerFromDraftLayer(layer, image.previewUrl);
});
const mergedLayerIds = new Set(layersRef.current.map((layer) => layer.id));
const appendedCandidateLayers = candidateLayers.filter(
(layer) => !mergedLayerIds.has(layer.id),
);
const appendedPreviewUrls = new Set(
appendedCandidateLayers.map((layer) => layer.src),
);
for (const image of result.value.images) {
if (!appendedPreviewUrls.has(image.previewUrl)) {
previewUrlsRef.current.delete(image.previewUrl);
URL.revokeObjectURL(image.previewUrl);
}
}
if (appendedCandidateLayers.length > 0) {
const nextLayers = [...layersRef.current, ...appendedCandidateLayers];
layersRef.current = nextLayers;
setLayers(nextLayers);
}
// Serialize behind every already queued autosave, then acknowledge the
// candidate together with the latest local canvas state. This keeps
// edits made while generation was running instead of hydrating the
// generation response's older full-canvas snapshot.
if (!(await persistDraft())) return;
pendingGenerationRef.current = null;
persistedDocumentVersionRef.current = documentVersionRef.current;
@@ -2535,7 +2627,7 @@ export function AssetCanvasSurface({
generationReferenceResourceIds,
generationTasks,
host.generation,
hydrateDraft,
applyDraftCandidate,
onSaveAttempt,
onWalletBalanceMayHaveChanged,
persistDraft,
@@ -2548,7 +2640,6 @@ export function AssetCanvasSurface({
const archiveFailedGeneration = useCallback(
async (generationId: string) => {
if (
!host.generation.archiveFailedGeneration ||
archivingGenerationIds.includes(generationId) ||
!generationTasks.some(
(task) =>
@@ -2707,17 +2798,12 @@ export function AssetCanvasSurface({
? (layers.find((layer) => layer.id === quickEditSourceLayerId) ?? null)
: null;
const quickEditPanelStyle: CSSProperties | null = quickEditSourceLayer
? {
left:
viewport.x +
(quickEditSourceLayer.x + quickEditSourceLayer.width / 2) *
viewport.scale,
top:
viewport.y +
(quickEditSourceLayer.y + quickEditSourceLayer.height) *
viewport.scale +
12,
}
? resolveQuickEditPanelPosition({
layer: quickEditSourceLayer,
viewport,
canvasSize,
panelSize: quickEditPanelSize,
})
: null;
const currentFinalLayer =
(draft?.lastCommit
@@ -2894,13 +2980,7 @@ export function AssetCanvasSurface({
label="导入图片"
title="导入本地图片"
icon={<Upload size={15} aria-hidden="true" />}
onClick={() => {
if (host.asset.importLocalImages) {
void handleLocalImport();
} else {
importInputRef.current?.click();
}
}}
onClick={() => void handleLocalImport()}
disabled={backgroundInteractionLocked}
>
{importPending ? '正在导入…' : '导入'}
@@ -3505,6 +3585,7 @@ export function AssetCanvasSurface({
>
{quickEditSourceLayer && quickEditPanelStyle ? (
<section
ref={quickEditPanelRef}
className="asset-canvas-surface__quick-edit-card"
style={quickEditPanelStyle}
role="region"
@@ -3668,8 +3749,7 @@ export function AssetCanvasSurface({
<span aria-hidden="true" />
<strong>{generationPhaseLabels[task.phase]}</strong>
{task.errorCode ? <small>{task.errorCode}</small> : null}
{task.phase === 'failed' &&
host.generation.archiveFailedGeneration ? (
{task.phase === 'failed' ? (
<CanvasChromeButton
label="删除失败任务"
title="删除失败任务"
@@ -3952,8 +4032,7 @@ export function AssetCanvasSurface({
{Math.round(task.progress)}%
</span>
) : null}
{task.phase === 'failed' &&
host.generation.archiveFailedGeneration ? (
{task.phase === 'failed' ? (
<CanvasChromeButton
label={`删除失败任务 ${task.generationId.slice(0, 8)}`}
title="删除失败任务"
@@ -239,6 +239,7 @@ export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort
message: '当前阶段使用明确 mock,不会提交真实 AI 生成请求',
});
return {
archiveFailedGeneration: unsupported,
generateImage: unsupported,
recoverImages: async () => ({
status: 'ok' as const,
@@ -771,6 +771,7 @@ const ResourceCard = memo(function ResourceCard({
className="game-resource-card-media-control"
aria-label={`${mediaActive ? '暂停' : '播放'} ${resource.label}`}
aria-pressed={mediaActive}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
if (!sourceUrl) {
@@ -892,6 +893,7 @@ export default function ProjectDevelopmentView({
} | null>(null);
const resourceCardDragRef = useRef<{
pointerId: number;
captureTarget: HTMLDivElement;
resourceId: string;
section: ResourceCategory;
startClientX: number;
@@ -900,7 +902,6 @@ export default function ProjectDevelopmentView({
startX: number;
startY: number;
changed: boolean;
captured: boolean;
} | null>(null);
const [resourceCardDragPreview, setResourceCardDragPreview] = useState<{
resourceId: string;
@@ -1429,17 +1430,24 @@ export default function ProjectDevelopmentView({
const activePageExtent = (activePageCategory
? resourceBaseExtentByCategory.get(activePageCategory)
: undefined) ?? {
x: 0,
y: 0,
width: RESOURCE_CANVAS_SECTION_MIN_WIDTH,
height: RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
};
const resourceCanvasNavigationBounds = useMemo(
() => ({
x: 0,
y: 0,
x: activePageExtent.x,
y: activePageExtent.y,
width: activePageExtent.width,
height: activePageExtent.height,
}),
[activePageExtent.height, activePageExtent.width],
[
activePageExtent.height,
activePageExtent.width,
activePageExtent.x,
activePageExtent.y,
],
);
const activeResourceCanvasViewport = activePageCategory
? resourceCanvasViewports[activePageCategory]
@@ -1690,14 +1698,33 @@ export default function ProjectDevelopmentView({
resourceCanvasPanRef.current = null;
}, []);
const cancelResourceCardDrag = useCallback(() => {
const drag = resourceCardDragRef.current;
if (!drag) {
return;
}
if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
drag.captureTarget.releasePointerCapture?.(drag.pointerId);
}
resourceCardDragRef.current = null;
setResourceCardDragPreview(null);
resourceDependencyOverlayRef.current?.clearDragPreview();
clearSkippedResourceCardClick();
}, [clearSkippedResourceCardClick]);
const selectResourceCanvasPage = useCallback(
(category: ResourceCategory) => {
cancelResourceCardDrag();
cancelResourceCanvasPan();
cancelQueuedResourceCanvasPageWheel();
resourceCanvasPageWheelRef.current.activeCategory = category;
setActiveResourceCategory(category);
},
[cancelQueuedResourceCanvasPageWheel, cancelResourceCanvasPan],
[
cancelQueuedResourceCanvasPageWheel,
cancelResourceCanvasPan,
cancelResourceCardDrag,
],
);
const advanceResourceCanvasPage = useCallback(
@@ -1722,15 +1749,16 @@ export default function ProjectDevelopmentView({
if (!nextCategory) {
return;
}
cancelResourceCardDrag();
cancelResourceCanvasPan();
resourceCanvasPageWheelRef.current.activeCategory = nextCategory;
setActiveResourceCategory(nextCategory);
},
[cancelResourceCanvasPan, resourcePageCategories],
[cancelResourceCanvasPan, cancelResourceCardDrag, resourcePageCategories],
);
useEffect(() => {
if (mode !== 'resources' || focusedResourceId !== null) {
if (mode !== 'resources') {
return undefined;
}
const canvas = resourceCanvasRef.current;
@@ -1774,7 +1802,7 @@ export default function ProjectDevelopmentView({
};
const wheel = (event: WheelEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest('button, input, textarea, select, a')) {
if (target?.closest('[data-resource-wheel-native="true"]')) {
return;
}
if (resourcePageCategories.length > 0) {
@@ -1955,7 +1983,6 @@ export default function ProjectDevelopmentView({
}, [
advanceResourceCanvasPage,
applyAnchoredResourceSectionZoom,
focusedResourceId,
mode,
queueResourceSectionZoomScroll,
resourcePageCategories.length,
@@ -2139,12 +2166,13 @@ export default function ProjectDevelopmentView({
pendingStepCount: 0,
pendingTimerId: null,
};
resourceCardDragRef.current = null;
cancelResourceCardDrag();
resourceCanvasPanRef.current = null;
resourceDependencyOverlayRef.current?.clearDragPreview();
clearSkippedResourceCardClick();
}, [
advanceFocusGeneration,
cancelResourceCardDrag,
clearSkippedResourceCardClick,
manifest.projectId,
projectPath,
@@ -2590,13 +2618,23 @@ export default function ProjectDevelopmentView({
) {
return;
}
const target = event.target instanceof Element ? event.target : null;
if (
target?.closest(
'.game-resource-card-media-control, audio, video, input, textarea, select, a',
)
) {
return;
}
const scale = resourceCanvasViewportRef.current.scale;
if (!Number.isFinite(scale) || scale <= 0) {
return;
}
cancelQueuedResourceCanvasPageWheel();
event.currentTarget.setPointerCapture?.(event.pointerId);
resourceCardDragRef.current = {
pointerId: event.pointerId,
captureTarget: event.currentTarget,
resourceId: resource.id,
section: resource.category,
startClientX: event.clientX,
@@ -2605,7 +2643,6 @@ export default function ProjectDevelopmentView({
startX: x,
startY: y,
changed: false,
captured: false,
};
},
[activePageCategory, cancelQueuedResourceCanvasPageWheel],
@@ -2625,9 +2662,7 @@ export default function ProjectDevelopmentView({
) {
return;
}
if (!drag.captured) {
drag.captured = true;
event.currentTarget.setPointerCapture?.(event.pointerId);
if (!drag.changed) {
skipNextResourceCardClickRef.current = drag.resourceId;
}
drag.changed = true;
@@ -2652,8 +2687,8 @@ export default function ProjectDevelopmentView({
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
if (drag.captureTarget.hasPointerCapture?.(event.pointerId)) {
drag.captureTarget.releasePointerCapture?.(event.pointerId);
}
resourceCardDragRef.current = null;
resourceDependencyOverlayRef.current?.clearDragPreview();
@@ -2763,6 +2798,8 @@ export default function ProjectDevelopmentView({
return;
}
advanceFocusGeneration();
cancelResourceCardDrag();
cancelResourceCanvasPan();
captureResourceListScrollPosition();
captureResourceSectionScrollPositions();
setSortMode(nextMode);
@@ -1259,28 +1259,42 @@ export function resourceCanvasSectionExtent(
positions: readonly ProjectResourceCanvasPosition[],
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
) {
const minX = Math.min(
0,
...positions.map((position) =>
position.x < 0 ? position.x - RESOURCE_CANVAS_COLUMN_GAP : 0,
),
);
const minY = Math.min(
0,
...positions.map((position) =>
position.y < 0 ? position.y - RESOURCE_CANVAS_ROW_GAP : 0,
),
);
const maxX = Math.max(
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
...positions.map(
(position) =>
position.x +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.width +
RESOURCE_CANVAS_COLUMN_GAP,
),
);
const maxY = Math.max(
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
...positions.map(
(position) =>
position.y +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.height +
RESOURCE_CANVAS_ROW_GAP,
),
);
return {
width: Math.max(
RESOURCE_CANVAS_SECTION_MIN_WIDTH,
...positions.map((position) =>
Math.ceil(
position.x +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.width +
RESOURCE_CANVAS_COLUMN_GAP,
),
),
),
height: Math.max(
RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
...positions.map((position) =>
Math.ceil(
position.y +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.height +
RESOURCE_CANVAS_ROW_GAP,
),
),
),
x: Math.floor(minX),
y: Math.floor(minY),
width: Math.ceil(maxX - minX),
height: Math.ceil(maxY - minY),
};
}
@@ -913,6 +913,43 @@ export function registerProjectWorkbenchFoundationTests() {
expect(screen.getByText('没有匹配资源')).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /文档/ }));
const dispatchPageWheel = (target: Element, deltaY = 160) => {
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaX: 0,
deltaY,
});
let result = true;
act(() => {
result = target.dispatchEvent(event);
});
expect(result).toBe(false);
};
dispatchPageWheel(
within(outline).getByRole('button', { name: /设计文档/ }),
);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
dispatchPageWheel(screen.getByRole('button', { name: /下一页\s*美术资源/ }));
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const documentCardButton = screen.getByRole('button', {
name: /打开资源详情:设计文档 section\.md/,
});
dispatchPageWheel(documentCardButton);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const documentWorld = dependencyCanvas.querySelector<HTMLElement>(
'[data-resource-viewport]',
);
@@ -940,8 +977,9 @@ export function registerProjectWorkbenchFoundationTests() {
deltaY: -120,
});
let zoomWheelResult = true;
const resetButton = screen.getByRole('button', { name: '复位资源画布' });
act(() => {
zoomWheelResult = dependencyCanvas.dispatchEvent(zoomWheel);
zoomWheelResult = resetButton.dispatchEvent(zoomWheel);
});
expect(zoomWheelResult).toBe(false);
expect(readViewport()[2]).toBeGreaterThan(viewportBeforeZoom[2]!);
@@ -949,6 +987,70 @@ export function registerProjectWorkbenchFoundationTests() {
screen.getByRole('region', { name: '设计文档资源画布' }),
).not.toBeNull();
fireEvent.click(
screen.getByRole('button', {
name: /打开资源详情:设计文档 section\.md/,
}),
);
expect(
screen.getByRole('dialog', { name: 'section.md' }),
).not.toBeNull();
dispatchPageWheel(dependencyCanvas);
expect(
screen.getByRole('region', { name: '美术资源资源画布' }),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const dragCard = screen
.getByRole('button', {
name: /打开资源详情:设计文档 section\.md/,
})
.closest<HTMLElement>('.game-resource-card');
expect(dragCard).not.toBeNull();
const cardSetPointerCapture = vi.fn();
const cardReleasePointerCapture = vi.fn();
Object.defineProperties(dragCard!, {
setPointerCapture: {
configurable: true,
value: cardSetPointerCapture,
},
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: {
configurable: true,
value: cardReleasePointerCapture,
},
});
const layoutUpdateCountBeforeCancelledDrag = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(dragCard!, {
pointerId: 91,
button: 0,
clientX: 40,
clientY: 50,
});
expect(cardSetPointerCapture).toHaveBeenCalledWith(91);
fireEvent.pointerMove(dragCard!, {
pointerId: 91,
clientX: 100,
clientY: 50,
});
fireEvent.click(within(outline).getByRole('button', { name: /美术资源/ }));
expect(cardReleasePointerCapture).toHaveBeenCalledWith(91);
fireEvent.pointerUp(dragCard!, {
pointerId: 91,
clientX: 100,
clientY: 50,
});
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdateCountBeforeCancelledDrag);
fireEvent.click(within(outline).getByRole('button', { name: /设计文档/ }));
const viewportBeforePan = readViewport();
fireEvent.pointerDown(dependencyCanvas, {
button: 0,
@@ -1364,6 +1466,31 @@ export function registerProjectWorkbenchFoundationTests() {
expect(video).not.toBeNull();
expect(video?.preload).toBe('auto');
fireEvent.loadedData(video!);
const layoutUpdatesBeforePlayback = invoke.mock.calls.filter(
([command]) => command === 'update_local_project_resource_canvas_layout',
).length;
fireEvent.pointerDown(videoControl, {
pointerId: 77,
button: 0,
clientX: 20,
clientY: 20,
});
fireEvent.pointerMove(videoControl, {
pointerId: 77,
clientX: 80,
clientY: 20,
});
fireEvent.pointerUp(videoControl, {
pointerId: 77,
clientX: 80,
clientY: 20,
});
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'update_local_project_resource_canvas_layout',
),
).toHaveLength(layoutUpdatesBeforePlayback);
fireEvent.click(videoControl);
expect(screen.queryByRole('dialog', { name: 'intro.mp4' })).toBeNull();
expect(screen.getByLabelText('资源依赖视图')).not.toBeNull();
@@ -3400,8 +3527,9 @@ export function registerProjectWorkbenchFoundationTests() {
expect(card?.getAttribute('style')).toContain('--resource-y: 24px');
});
expect(card).not.toBeNull();
const setCardPointerCapture = vi.fn();
Object.defineProperties(card, {
setPointerCapture: { configurable: true, value: vi.fn() },
setPointerCapture: { configurable: true, value: setCardPointerCapture },
hasPointerCapture: { configurable: true, value: vi.fn(() => true) },
releasePointerCapture: { configurable: true, value: vi.fn() },
});
@@ -3412,6 +3540,7 @@ export function registerProjectWorkbenchFoundationTests() {
clientX: 20,
clientY: 30,
});
expect(setCardPointerCapture).toHaveBeenCalledWith(11);
fireEvent.pointerMove(cardButton, {
pointerId: 11,
clientX: -100,
@@ -31,10 +31,12 @@ import {
import {
AssetCanvasSurface,
generationAspectRatioForOriginalImage,
resolveQuickEditPanelPosition,
type RenderAssetCanvasImage,
shouldApplyAssetCanvasDraftCandidate,
} from '../src/features/asset-canvas/AssetCanvasSurface';
import {
createMockImageCanvasGenerationPort,
createTauriImageCanvasHostAdapter,
type LocalAssetCommittedEvent,
type TauriImageCanvasHostAdapter,
@@ -176,6 +178,8 @@ function memoryHost(input?: {
importGate?: Deferred<unknown>;
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
generationFailureBeforeAcceptance?: { code: string; message: string };
generationThrowsBeforeAcceptance?: boolean;
generationFailure?: { code: string; message: string };
updateFailure?: { code: string; message: string };
recoverFailure?: { code: string; message: string };
@@ -459,12 +463,46 @@ function memoryHost(input?: {
sourceLayerId: generationInput.sourceLayerId,
placeholder: generationInput.placeholder,
});
if (input?.generationThrowsBeforeAcceptance) {
throw new Error('生成宿主在接受前异常退出');
}
if (input?.generationFailureBeforeAcceptance) {
return {
status: 'failed' as const,
code: input.generationFailureBeforeAcceptance.code,
message: input.generationFailureBeforeAcceptance.message,
};
}
if (!draft) throw new Error('draft missing');
const acceptedGeneration = {
generationId: generationInput.generationId,
intentId: generationInput.intentId,
phase: 'generation-running' as const,
referenceResourceIds: generationInput.referenceResourceIds,
outputAssetId: null,
sourceLayerId: generationInput.sourceLayerId,
placeholder: generationInput.placeholder,
errorCode: null,
createdAt: 1,
updatedAt: 1,
};
draft = {
...draft,
revision: draft.revision + 1,
generations: [
...draft.generations.filter(
(record) => record.generationId !== generationInput.generationId,
),
acceptedGeneration,
],
};
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
phase: 'generation-accepted',
progress: 10,
errorCode: null,
draftRevision: draft.revision,
});
generationInput.onProgress?.({
intentId: generationInput.intentId,
@@ -472,16 +510,40 @@ function memoryHost(input?: {
phase: 'generation-running',
progress: 35,
errorCode: null,
draftRevision: draft.revision,
});
await input?.generationGate?.promise;
if (input?.generationFailure) {
const failedGeneration = {
...acceptedGeneration,
phase: 'failed' as const,
errorCode: input.generationFailure.code,
updatedAt: 2,
};
draft = {
...draft,
revision: draft.revision + 1,
generations: [
...draft.generations.filter(
(record) => record.generationId !== generationInput.generationId,
),
failedGeneration,
],
};
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
phase: 'failed',
progress: null,
errorCode: input.generationFailure.code,
draftRevision: draft.revision,
});
return {
status: 'failed' as const,
code: input.generationFailure.code,
message: input.generationFailure.message,
};
}
if (!draft) throw new Error('draft missing');
const candidateLayer = {
layerId: `generated-layer-${generationInput.generationId}`,
resourceId: `draft-media:generated-${generationInput.generationId}`,
@@ -822,6 +884,51 @@ afterEach(() => {
});
describe('Tauri 素材创作无限画布独立 Surface', () => {
it('快速编辑卡在四边和窄 viewport 内保持可达', () => {
const panelSize = { width: 320, height: 240 };
const canvasSize = { width: 360, height: 320 };
expect(
resolveQuickEditPanelPosition({
layer: { x: -100, y: -100, width: 40, height: 40 },
viewport: { x: 0, y: 0, scale: 1 },
canvasSize,
panelSize,
}),
).toEqual({ left: 172, top: 12 });
expect(
resolveQuickEditPanelPosition({
layer: { x: 340, y: 300, width: 40, height: 40 },
viewport: { x: 0, y: 0, scale: 1 },
canvasSize,
panelSize,
}),
).toEqual({ left: 188, top: 48 });
expect(
resolveQuickEditPanelPosition({
layer: { x: 170, y: 40, width: 20, height: 20 },
viewport: { x: 0, y: 0, scale: 1 },
canvasSize: { width: 220, height: 180 },
panelSize: { width: 672, height: 352 },
}),
).toEqual({ left: 110, top: 12 });
});
it('mock Host Port 始终提供归档方法并结构化返回能力不可用', async () => {
const generation = createMockImageCanvasGenerationPort();
expect(typeof generation.archiveFailedGeneration).toBe('function');
await expect(
generation.archiveFailedGeneration({
scope,
expectedDraftRevision: 0,
generationId: '22222222-2222-4222-8222-222222222222',
}),
).resolves.toEqual(
expect.objectContaining({
status: 'unsupported-capability',
capability: 'externalEditorGeneration',
}),
);
});
it('从精修文件名剥离历史提交后缀并保持后端合法名称', () => {
expect(
canonicalAssetBaseName(
@@ -2727,11 +2834,36 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
await act(async () => gate.resolve());
await screen.findByRole('button', { name: /选择图层 .*候选图/ });
expect(first.getAttribute('aria-pressed')).toBe('true');
expect(second.getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByRole('status', { name: /^生成任务:/ })).toBeNull();
fireEvent.click(within(taskSidebar).getByRole('tab', { name: '已完成 1' }));
expect(within(taskSidebar).getByText('候选图片已加入画布')).toBeTruthy();
expect(within(taskSidebar).getByText('阶段进度 100%')).toBeTruthy();
});
it('宿主接受生成前异常不会把幽灵 running 记录写入草稿', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope),
generationThrowsBeforeAcceptance: true,
});
const view = renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '宿主接受前失败不得留下公开运行记录' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
await screen.findByRole('status', { name: '生成任务:图片生成失败' });
expect(memory.getDraft()?.generations).toEqual([]);
view.unmount();
renderSurface(memory.host);
await screen.findByText('画布可编辑');
expect(screen.queryByRole('status', { name: /^生成任务:/ })).toBeNull();
expect(memory.getDraft()?.generations).toEqual([]);
});
it('生成失败保留失败任务和占位,画布不锁定且参数可继续修改', async () => {
const prompt = '保留参数的原创游戏场景';
const memory = memoryHost({
@@ -749,11 +749,46 @@ describe('resource canvas variable card geometry', () => {
y: 0,
});
expect(resourceCanvasSectionExtent(layout.positions, cardSizes)).toEqual({
x: 0,
y: 0,
width: 620,
height: 416,
});
});
it('includes negative manual coordinates in the fit bounds', () => {
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 extent = resourceCanvasSectionExtent(
positions,
new Map([
['negative-card', { width: 180, height: 128 }],
['positive-card', { width: 220, height: 180 }],
]),
);
expect(extent).toEqual({
x: -256,
y: -176,
width: 972,
height: 692,
});
});
it('accepts card sizes directly on resource items for hook integration', () => {
const wide = {
...resource('wide', 'art', 0),
@@ -235,7 +235,7 @@ type ProjectResourceCanvasLayout = {
revision: number;
positions: Array<{
resourceId: string;
section: 'document' | 'version' | 'art' | 'audio';
section: 'document' | 'art' | 'audio' | 'code' | 'version';
x: number;
y: number;
manuallyPlaced: boolean;
@@ -344,14 +344,14 @@ type UpdateProjectResourceCanvasLayoutResult =
### 5.3 资源类型与替换兼容性(P1)
实现状态(2026-08-22):当前资源投影与栏目页顺序收口为“文档 -> 美术资源 -> 音乐音效资源 -> 游戏代码 -> 项目版本”。文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
实现状态(2026-08-23):当前资源投影与栏目页顺序收口为“设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本”。设计文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术资源接收图片、SVG、动画和视频类产物;音乐音效接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
```ts
type ProjectResourceDescriptor = {
resourceId: string;
category: 'document' | 'version' | 'art' | 'audio';
category: 'document' | 'art' | 'audio' | 'code' | 'version';
subtype: string;
width?: number;
height?: number;
@@ -495,7 +495,7 @@ type ProjectAgentMudPointAttribution = {
4. 两个窗口基于同一 revision 执行资源自动协调写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。
5. 布局文件缺失的旧项目可以无迁移打开;损坏、未知 schema、身份冲突、超限和链接文件失败关闭,且原文件不被空布局覆盖。
6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。
7. `1280×720` 最小横屏下完全空项目可通过分区展览查看全部栏目,非空项目可通过栏目大纲和滚轮访问全部空栏目,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见;`1280×800` 继续作为默认窗口基线。
7. `1280×720` 最小横屏下完全空项目可通过分区展览查看全部栏目,非空项目可通过栏目大纲和滚轮访问全部栏目(包括空栏目,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见;`1280×800` 继续作为默认窗口基线。
### 7.3 P1 资源依赖关系图验收
@@ -523,7 +523,7 @@ type ProjectAgentMudPointAttribution = {
### 7.5 资源分区独立高度验收
1. 四个分区均可独立缩小、放大和恢复默认高度;任一分区变化时其它分区高度、资源卡尺寸和媒体比例不变。到达上下限时对应按钮同时具备正确的 disabled 视觉与辅助技术语义。
2. 高度、倍率和分区内部滚动按 `projectId + dependency|type + document|version|art|audio` 隔离,外层资源画布位置按 `projectId + dependency|type` 隔离;切换模式、切换项目和打开 / 关闭资源详情后,分别恢复当前会话中的内外滚动位置,且键盘焦点不被无关重置。
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` 并在卸载时清理全部监听。
@@ -14322,7 +14322,7 @@
## 2026-08-20 AGC 图片精修候选与最终图事务
- 只有可栅格编辑的图片资源拥有持续精修草稿;同一 `sourceAssetId` 只恢复一个活动 refine 草稿。生成成功只把 PNG 写入草稿私有 `draft-media` 并追加候选图层,不自动修改 manifest,也不自动关闭画布。
- 每次生成在提交远端任务前冻结并持久化 `sourceLayerId` 与有边界的 `placeholder`任务侧栏和画布占位共同消费 generation record;成功候选复用冻结落点,失败只终结当前任务,重启恢复不得重新提交或重复扣费。
- 每次生成在前端确认时先冻结 `sourceLayerId` 与有边界的 `placeholder`,但宿主创建私有 ledger 并接受请求前只保留内存任务,不得把 `generation-running` 写入公开草稿。宿主接受后,任务侧栏和画布占位共同消费公开 generation record;成功候选复用冻结落点,失败只终结当前任务,重启恢复不得重新提交或重复扣费。
- “设为最终图”是唯一正式资源切换入口:refine 保持原 asset ID,把正式文件切换到 `assets/canvas/<name>--<commitId>.png`,并用候选图层 ID 与媒体 SHA-256 交叉验证 staging、ledger 和 transaction journal。manifest 继续是当前最终图权威。
- 最终图事务完成或恢复后,草稿必须回到 `editing`,清空 `pendingCommit`,保留其它候选与 generation records,并在 `lastCommit.sourceLayerId / mediaSha256` 记录当前最终候选;不能把持续精修草稿永久停在 `committed`
- 资源详情保持非模态,不能卸载资源工具栏或背景画板。图片卡展示尺寸来自安全预览元数据,受 `220x180`、最小短边 `96``1:2..2:1` 约束;碰撞、世界范围和依赖连线共同消费同一实际矩形。
@@ -14331,7 +14331,7 @@
- refine 主来源不能仅凭已有 Editor Resource ID 直接复用;历史 Game Agent 私有 kind 必须基于本地正式图片重新登记为 External v1 canonical kind,并使用绑定服务身份、owner、Editor Project、源 SHA256 和 canonical kind 的本地私有缓存避免重复上传。不得扩大 External v1 快速编辑白名单或向 edit DTO 补发 `assetKind`
- Tauri 图片导入采用原生多选与 Rust 批量事务:安全读取、媒体安装、图层绑定和一次 draft revision 推进必须作为一个可恢复单元,前端只 hydrate 权威 draft,不再依赖隐藏 input 加后续 autosave 完成正式绑定。
- 生成失败任务的“删除”固定为归档私有 ledger 并移出 draft 公开投影;只允许明确 `failed`,结果未知和 `reconciliation-required` 必须继续留在恢复队列。任务侧栏折叠是会话 UI 状态,不进入业务持久化。
- 生成失败任务的“删除”固定为归档私有 ledger 并移出 draft 公开投影;只允许明确 `failed`,结果未知和 `reconciliation-required` 必须继续留在恢复队列。归档顺序固定为私有 `archiving`、删除并回读公开 generation、私有 `archived`;恢复必须幂等收敛 `archiving`,也要清理历史 `archived` 账本残留的公开记录,且不得依赖生成服务凭据。任务侧栏折叠是会话 UI 状态,不进入业务持久化。
## 2026-08-21 Game Agent 图片生成恢复与正式图来源身份
@@ -14392,3 +14392,10 @@
- 素材生成确认、离开确认和旧服务身份确认沿用现有独立 modal,不在当前面板下方追加内容。modal 打开后焦点必须进入对话框,并同时隔离工具栏、画布视口、缩放/小地图、状态区和并存操作层,使背景从键盘焦点顺序与 accessibility tree 中退出。
- `Tab` / `Shift+Tab` 必须在当前 modal 内双向循环;非异步 pending 状态允许 `Escape` 安全关闭。关闭后优先恢复到原触发器,自动弹出的 modal 则回退到可操作的工具栏入口,不能把焦点遗留在已卸载节点或被隔离背景中。
## 2026-08-23 Game Agent 资源分页与精修生成并发合同
- 资源管理固定展示 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本` 五个栏目;空栏目仍可从 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 四边。
@@ -2,7 +2,7 @@
## 目标
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:每个非空栏目拥有一个铺满资源管理区域的独立画布,栏目大纲悬浮在画布左侧中间,只显示图标和栏目名称,不占用画布布局宽度;顶部显示当前栏目标题,底部显示下一页标题。普通滚轮切换栏目,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:固定五个栏目各自拥有一个铺满资源管理区域的独立画布,空栏目也保留可打开的空画布;栏目大纲悬浮在画布左侧中间,只显示图标和栏目名称,不占用画布布局宽度;顶部显示当前栏目标题,底部显示下一页标题。普通滚轮切换栏目,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
图片资源从“编辑资源”进入持续精修草稿后,点击任意图片直接在图片下方显示快速编辑卡。每次快速编辑创建新的候选图,不覆盖来源图层;生成任务立即在画布中创建占位并进入任务侧栏。用户显式选择候选图“设为最终图”后,保持原资产 ID 不变,以事务方式切换 manifest 指向的正式 PNG;精修草稿和其它候选图继续保留。
@@ -12,8 +12,8 @@
### 栏目分页投影与设计文档缺失边界
- 非空栏目页只由当前权威资源投影构造:manifest assets、已完成任务的 artifacts、用户已导入附件和持久化 Agent 文本回执。前端不得扫描项目目录自行发明资源。
- 设计文档栏因此只有在上述来源存在 Markdown / TXT / JSON / YAML 等文档类条目,或存在 Agent 文本回执时才出现。若 manifest 没有文档资产、任务 artifacts 未完成且没有附件 / 回执,资源页不显示设计文档页;这是生成或登记缺失,不由 UI 补假数据。
- 栏目内容只由当前权威资源投影构造:manifest assets、已完成任务的 artifacts、用户已导入附件和持久化 Agent 文本回执。前端不得扫描项目目录自行发明资源。
- 固定栏目页始终为 `设计文档 -> 美术资源 -> 音乐音效 -> 游戏代码 -> 项目版本`。若某栏目没有对应投影,Dock 仍显示该栏目并允许打开空画布;空画布只表达“当前没有已登记资源”,不由 UI 补假数据。
- Direct Codex 的游戏生成链路以 `game/index.html``game/style.css``game/game.js` 作为代码资产登记;是否额外产出设计文档由当前任务和 Agent 决定,资源页只消费已登记结果。
### 持续精修草稿
@@ -22,7 +22,7 @@
- 草稿 JSON 位于 `.agent/workbench/asset-canvas/drafts/<draftId>.json`,只保存图层、viewport、选择、generation 投影和提交记录,不内嵌图片 Base64。
- 入口图片图层使用 `project-asset` 引用稳定 `sourceAssetId`。AI 生成、导入和中间图片使用不可变 `draft-media`,二进制位于 `.agent/workbench/asset-canvas/media/<draftId>/<mediaId>.png`,引用同时冻结 SHA256、字节数和像素宽高。
- 候选媒体只属于精修草稿,不写入 manifest、不出现在资源总览,也不成为正式依赖。删除图层只删除草稿引用;物理媒体在确认不被草稿、generation ledger、staging、transaction journal 或恢复记录引用后延迟清理。
- generation 占位与稳定 `generationId` 绑定,持久化落点、目标尺寸、阶段和结果图层身份;成功候选必须使用同一落点,页面重启后从草稿和私有 ledger 恢复。
- generation 占位与稳定 `generationId` 绑定。确认后 UI 立即显示内存占位;宿主创建私有 ledger 并接受请求后,才把落点、目标尺寸、阶段和结果图层身份投影到公开草稿。接受前失败或异常不得留下公开 `generation-running` 记录;成功候选必须使用同一落点,页面重启后从草稿和私有 ledger 恢复。
### 最终图事务
@@ -75,7 +75,7 @@
### 失败生成任务归档与任务侧栏
- 用户界面的“删除失败任务”语义是归档,不物理销毁私有 generation ledger。只有平台明确失败的 `failed` 任务可归档;`reconciliation-required`、已受理、运行中和结果未知任务不得移出恢复队列。
- 归档命令校验 project、draft、generation 与 expected draft revision私有 ledger 写入 `archived/archivedAt`,再从 `draft.generations` 公开投影移除并推进一次 revision。重放必须幂等;中途退出后以 archived ledger 收敛,重载不得复活已归档任务
- 归档命令校验 project、draft、generation 与 expected draft revision先把私有 ledger 写入可重放的 `archiving/archivedAt`,再从 `draft.generations` 移除公开投影并推进一次 revision;草稿删除成功并回读后才发布 `archived``archiving` 以及历史上已写 `archived` 但仍残留公开记录的状态都必须在恢复阶段幂等收敛,且不依赖图片生成服务凭证
- 失败占位和右上角任务项复用同一个归档动作,成功后两处同时消失,其它任务、图层和候选不受影响。
- 任务侧栏折叠只属于当前会话 UI 状态,不写入 draft 或 manifest。视觉和交互复用现役美术画布:右上角独立“任务列表”图标按钮、20rem 白色模糊卡、总数徽标、`排队/生成中``已完成` 双 Tab、状态圆形图标、阶段进度和时间信息;折叠后只保留图标按钮,不显示摘要卡。用户显式新建 generation 时自动展开并切回活动 Tab,普通进度更新不得推翻用户已有折叠选择。Game Agent 的失败归档作为任务行扩展保留。
@@ -96,7 +96,7 @@
- 快速编辑卡底部统一承载“删除”“设为最终图”“修改”。“删除”只删除当前绑定图层并进入画布 history;“设为最终图”只对完整候选媒体可用;“修改”创建新 generation,不覆盖来源图层。
- 页面级返回、导入、撤销和重做不属于图片浮动工具条,本次不以复制另一套图片操作条替代已退役工具条。
- refine `/api/external/v1/editor/images/edits` 严格遵循 `EditorImageEditRequest`,不得携带 `assetKind``sourceImageSrc``sourceResourceId`。Game Agent 本地 kind 在登记编辑器来源前映射到现役 canonical kind;服务端安全业务错误必须结构化返回,不能把全部 400 折叠为同一个“平台明确拒绝”。
- 生成提交后立即创建画布占位和任务侧栏记录;排队、运行、远端完成、下载、候选就绪、失败和对账是任务级状态,不得用全屏 `canvas.generating/canvas.failed` 遮挡或终结整张画布。重新进入时,关键提交事务由 `host.recover()` 在 hydrate 前收敛;草稿 hydrate 完成后立即进入 `canvas.editing`,耗时的 `recoverImages()` 转为后台任务投影恢复,只更新任务、候选和 notice,不能继续锁住画布或因后台恢复失败切换为全屏失败。真正阻断草稿/提交/恢复的错误使用挂到 `document.body` 的全窗口 portal,必须覆盖整个 Tauri WebView,而不是只覆盖左侧画布区域。
- 生成提交后立即创建内存画布占位和任务侧栏记录,宿主接受后再持久化公开 generation 投影;排队、运行、远端完成、下载、候选就绪、失败和对账是任务级状态,不得用全屏 `canvas.generating/canvas.failed` 遮挡或终结整张画布。候选持久化必须先写入并回读候选媒体、图层和公开记录,再发布私有 `candidate-ready` 终态;生成返回时前端只合并候选与 generation 权威事实,并在保存队列屏障后把生成期间的最新本地画布状态写回,禁止全量 hydrate 覆盖并发编辑。重新进入时,关键提交事务由 `host.recover()` 在 hydrate 前收敛;草稿 hydrate 完成后立即进入 `canvas.editing`,耗时的 `recoverImages()` 转为后台任务投影恢复,只更新任务、候选和 notice,不能继续锁住画布或因后台恢复失败切换为全屏失败。真正阻断草稿/提交/恢复的错误使用挂到 `document.body` 的全窗口 portal,必须覆盖整个 Tauri WebView,而不是只覆盖左侧画布区域。
## 美术预览几何
+2 -2
View File
@@ -200,7 +200,7 @@ export interface ImageCanvasProjectPort {
}
export interface ImageCanvasAssetPort {
importLocalImages?(input: {
importLocalImages(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
viewportSize: { width: number; height: number };
@@ -225,7 +225,7 @@ export interface ImageCanvasAssetPort {
}
export interface ImageCanvasGenerationPort {
archiveFailedGeneration?(input: {
archiveFailedGeneration(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
generationId: string;