修复资源分页与图片生成事务
修复资源画布滚轮、拖拽取消、负坐标与快速编辑卡边界 修复生成占位、候选持久化、归档恢复与并发保存顺序 收紧 Host Port 合同并补齐回归测试 统一栏目合同并更新权威文档与项目记忆
This commit is contained in:
@@ -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);
|
||||
|
||||
+36
-22
@@ -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,而不是只覆盖左侧画布区域。
|
||||
|
||||
## 美术预览几何
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user