完善 Game Agent 资源画布与图片精修生成事务 (#181)
Project CI / Repository checks (push) Successful in 4m5s
Project CI / Frontend tests (push) Successful in 4m48s
Project CI / Backend tests (push) Successful in 6m6s
Project CI / Native shell tests (push) Successful in 15m24s

## 背景

本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。

当前远端比较基线:

- base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d`
- head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472`
- 相对 base:28 commits、33 files(`+3746 / -1333`)

## 主要改动

### 资源管理分页自由画布

- 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。
- `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。
- 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。
- 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。
- 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。
- 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。

### 图片持续精修与生成事务

- 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。
- 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。
- 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。
- 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。
- 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。

### 合同与文档

- `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。
- 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。
- 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。

## 验证

CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功:

- [x] Repository checks
- [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests)
- [x] Backend tests
- [x] Native shell tests

另有本地定向检查:

- [x] `npm run agc:typecheck`
- [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归
- [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试
- [x] `cargo fmt --check`
- [x] `npm run check:encoding`
- [x] `git diff --check`

## 评审 provenance

- review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。
- review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。
- 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。
- 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。

## 影响范围

- 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。
- 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。
- 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181
Co-authored-by: suzmii <suzmii@qq.com>
Co-committed-by: suzmii <suzmii@qq.com>
This commit was merged in pull request #181.
This commit is contained in:
2026-08-24 20:42:58 +08:00
committed by 段舒康
parent b00a3f8057
commit 532c6d51fd
33 changed files with 3770 additions and 1357 deletions
@@ -623,6 +623,14 @@ pub(crate) fn update_local_project_asset_canvas_draft(
update_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn acknowledge_local_project_asset_canvas_candidate_layers(
input: AcknowledgeAssetCanvasCandidateLayersInput,
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
acknowledge_candidate_layers_at(&root, &input)
}
#[tauri::command]
pub(crate) async fn import_local_project_asset_canvas_images(
app: tauri::AppHandle,
@@ -396,11 +396,70 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32
}
}
#[derive(Clone, Copy)]
enum TiffByteOrder {
LittleEndian,
BigEndian,
}
fn read_tiff_u16(bytes: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option<u16> {
let end = offset.checked_add(2)?;
let value: [u8; 2] = bytes.get(offset..end)?.try_into().ok()?;
Some(match byte_order {
TiffByteOrder::LittleEndian => u16::from_le_bytes(value),
TiffByteOrder::BigEndian => u16::from_be_bytes(value),
})
}
fn read_tiff_u32(bytes: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option<u32> {
let end = offset.checked_add(4)?;
let value: [u8; 4] = bytes.get(offset..end)?.try_into().ok()?;
Some(match byte_order {
TiffByteOrder::LittleEndian => u32::from_le_bytes(value),
TiffByteOrder::BigEndian => u32::from_be_bytes(value),
})
}
fn detect_jpeg_exif_orientation(app1_payload: &[u8]) -> Option<u16> {
let tiff = app1_payload.strip_prefix(b"Exif\0\0")?;
let byte_order = match tiff.get(..2)? {
b"II" => TiffByteOrder::LittleEndian,
b"MM" => TiffByteOrder::BigEndian,
_ => return None,
};
if read_tiff_u16(tiff, 2, byte_order)? != 42 {
return None;
}
let ifd_offset = usize::try_from(read_tiff_u32(tiff, 4, byte_order)?).ok()?;
let entry_count = usize::from(read_tiff_u16(tiff, ifd_offset, byte_order)?);
let entries_start = ifd_offset.checked_add(2)?;
let entries_end = entries_start.checked_add(entry_count.checked_mul(12)?)?;
if entries_end > tiff.len() {
return None;
}
for entry_index in 0..entry_count {
let entry_offset = entries_start.checked_add(entry_index.checked_mul(12)?)?;
if read_tiff_u16(tiff, entry_offset, byte_order)? != 0x0112 {
continue;
}
if read_tiff_u16(tiff, entry_offset.checked_add(2)?, byte_order)? != 3
|| read_tiff_u32(tiff, entry_offset.checked_add(4)?, byte_order)? != 1
{
return None;
}
let orientation = read_tiff_u16(tiff, entry_offset.checked_add(8)?, byte_order)?;
return (1..=8).contains(&orientation).then_some(orientation);
}
None
}
fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if !bytes.starts_with(&[0xff, 0xd8]) {
return None;
}
let mut index = 2usize;
let mut exif_orientation = None;
let mut dimensions = None;
while index + 3 < bytes.len() {
if bytes[index] != 0xff {
index += 1;
@@ -424,21 +483,28 @@ fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
if segment_len < 2 || index.checked_add(segment_len)? > bytes.len() {
return None;
}
if matches!(
marker,
0xc0 | 0xc1
| 0xc2
| 0xc3
| 0xc5
| 0xc6
| 0xc7
| 0xc9
| 0xca
| 0xcb
| 0xcd
| 0xce
| 0xcf
) && segment_len >= 7
if marker == 0xe1 && exif_orientation.is_none() {
let payload_start = index.checked_add(2)?;
let payload_end = index.checked_add(segment_len)?;
exif_orientation = detect_jpeg_exif_orientation(bytes.get(payload_start..payload_end)?);
}
if dimensions.is_none()
&& matches!(
marker,
0xc0 | 0xc1
| 0xc2
| 0xc3
| 0xc5
| 0xc6
| 0xc7
| 0xc9
| 0xca
| 0xcb
| 0xcd
| 0xce
| 0xcf
)
&& segment_len >= 7
{
let height = u32::from(u16::from_be_bytes([
*bytes.get(index + 3)?,
@@ -448,11 +514,17 @@ fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
*bytes.get(index + 5)?,
*bytes.get(index + 6)?,
]));
return Some((width, height));
dimensions = Some((width, height));
}
index += segment_len;
}
None
dimensions.map(|(width, height)| {
if matches!(exif_orientation, Some(5..=8)) {
(height, width)
} else {
(width, height)
}
})
}
fn detect_webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
@@ -626,6 +698,72 @@ mod tests {
.expect("valid 1x1 png")
}
fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec<u8> {
let mut bytes = vec![0xff, 0xd8];
if let Some(payload) = app1_payload {
let segment_len = u16::try_from(payload.len() + 2).expect("APP1 length");
bytes.extend_from_slice(&[0xff, 0xe1]);
bytes.extend_from_slice(&segment_len.to_be_bytes());
bytes.extend_from_slice(payload);
}
bytes.extend_from_slice(&[0xff, 0xc0, 0, 7, 8]);
bytes.extend_from_slice(&height.to_be_bytes());
bytes.extend_from_slice(&width.to_be_bytes());
bytes.extend_from_slice(&[0xff, 0xd9]);
bytes
}
fn exif_orientation_payload(orientation: u16, little_endian: bool) -> Vec<u8> {
let mut payload = b"Exif\0\0".to_vec();
if little_endian {
payload.extend_from_slice(b"II");
payload.extend_from_slice(&42_u16.to_le_bytes());
payload.extend_from_slice(&8_u32.to_le_bytes());
payload.extend_from_slice(&1_u16.to_le_bytes());
payload.extend_from_slice(&0x0112_u16.to_le_bytes());
payload.extend_from_slice(&3_u16.to_le_bytes());
payload.extend_from_slice(&1_u32.to_le_bytes());
payload.extend_from_slice(&orientation.to_le_bytes());
} else {
payload.extend_from_slice(b"MM");
payload.extend_from_slice(&42_u16.to_be_bytes());
payload.extend_from_slice(&8_u32.to_be_bytes());
payload.extend_from_slice(&1_u16.to_be_bytes());
payload.extend_from_slice(&0x0112_u16.to_be_bytes());
payload.extend_from_slice(&3_u16.to_be_bytes());
payload.extend_from_slice(&1_u32.to_be_bytes());
payload.extend_from_slice(&orientation.to_be_bytes());
}
payload.extend_from_slice(&[0, 0]);
payload
}
#[test]
fn jpeg_dimensions_apply_little_and_big_endian_exif_rotation() {
for (orientation, little_endian) in [(6, true), (8, false)] {
let exif = exif_orientation_payload(orientation, little_endian);
assert_eq!(
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(&exif))),
Some((20, 40))
);
}
}
#[test]
fn jpeg_dimensions_keep_raw_size_for_normal_or_malformed_exif() {
let normal = exif_orientation_payload(1, true);
assert_eq!(
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(&normal))),
Some((40, 20))
);
let malformed = b"Exif\0\0II\x2a\0\xff\xff\xff\x7f";
assert_eq!(
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(malformed))),
Some((40, 20))
);
}
#[test]
fn image_inspect_accepts_magic_bytes_without_trusting_extension() {
let root = tempfile::tempdir().expect("temp root");
@@ -2366,6 +2366,7 @@ fn main() {
read_local_project_asset_canvas_draft,
discover_local_project_asset_canvas_draft,
update_local_project_asset_canvas_draft,
acknowledge_local_project_asset_canvas_candidate_layers,
import_local_project_asset_canvas_images,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
@@ -1560,12 +1560,7 @@ pub(crate) fn update_asset_canvas_draft_at(
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.status = input.status.clone();
draft.canvas = protect_candidate_layers_before_frontend_update_at(
root,
&draft,
draft.revision,
&input.canvas,
)?;
draft.canvas = protect_candidate_layers_before_frontend_update_at(root, &draft, &input.canvas)?;
// Generation lifecycle facts remain backend-authoritative, while the card
// placement is ordinary canvas state and may be changed by the user.
// Merge only placeholders so an older autosave cannot erase phase, output,
@@ -4107,100 +4102,117 @@ fn find_superseding_asset_canvas_commit(
if final_image_state_for_journal(root, journal)? != AssetCanvasFinalImageState::Matches {
return Ok(false);
}
for later in read_asset_canvas_transaction_journals(root)? {
if later.commit_id == journal.commit_id
|| later.project_id != journal.project_id
|| later.draft_id != journal.draft_id
|| later.asset_id != journal.asset_id
{
continue;
}
let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else {
continue;
};
if !asset_canvas_journal_ledger_identity_matches(&later, &ledger)
|| ledger.status != AssetCanvasLedgerStatus::Committed
|| ledger.committed_project_revision != Some(later.target_project_revision)
|| ledger.committed_draft_revision != Some(later.target_draft_revision)
|| ledger.asset_id.as_deref() != Some(later.asset_id.as_str())
|| ledger.event_payload.is_none()
{
continue;
}
if later.expected_project_revision != journal.target_project_revision
|| later.manifest_before_sha256 != journal.manifest_after_sha256
|| later.project_revision_before_sha256.as_deref()
!= Some(journal.project_revision_after_sha256.as_str())
|| later.final_image_relative_path == journal.final_image_relative_path
|| !matches!(
let journals = read_asset_canvas_transaction_journals(root)?;
let manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&journal.commit_id,
"manifest.after.json",
&journal.manifest_after_sha256,
)?;
let revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&journal.commit_id,
"project-revision.after.json",
&journal.project_revision_after_sha256,
)?;
let mut pending = vec![(journal.clone(), manifest_after, revision_after)];
let mut visited = HashSet::from([journal.commit_id.clone()]);
while let Some((previous, previous_manifest_after, previous_revision_after)) = pending.pop() {
for later in &journals {
if visited.contains(&later.commit_id)
|| later.project_id != previous.project_id
|| later.draft_id != previous.draft_id
|| later.asset_id != previous.asset_id
|| later.expected_project_revision != previous.target_project_revision
|| later.manifest_before_sha256 != previous.manifest_after_sha256
|| later.project_revision_before_sha256.as_deref()
!= Some(previous.project_revision_after_sha256.as_str())
|| later.final_image_relative_path == previous.final_image_relative_path
{
continue;
}
let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else {
continue;
};
let finalized_link = matches!(
later.stage,
AssetCanvasTransactionStage::Committed
| AssetCanvasTransactionStage::EventAttempted
)
{
continue;
}
) && ledger.status == AssetCanvasLedgerStatus::Committed;
let superseded_link = later.stage == AssetCanvasTransactionStage::Superseded
&& ledger.status == AssetCanvasLedgerStatus::Superseded;
if !asset_canvas_journal_ledger_identity_matches(later, &ledger)
|| (!finalized_link && !superseded_link)
|| ledger.committed_project_revision != Some(later.target_project_revision)
|| ledger.committed_draft_revision != Some(later.target_draft_revision)
|| ledger.asset_id.as_deref() != Some(later.asset_id.as_str())
|| ledger.event_payload.is_none()
{
continue;
}
let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.before.json",
&later.manifest_before_sha256,
)?;
let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.before.json",
later
.project_revision_before_sha256
.as_deref()
.ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?,
)?;
let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.after.json",
&later.manifest_after_sha256,
)?;
let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.after.json",
&later.project_revision_after_sha256,
)?;
let later_manifest_before_sha256 =
asset_canvas_sha256(&asset_canvas_json_bytes(&later_manifest_before)?);
let later_revision_before_sha256 =
asset_canvas_sha256(&asset_canvas_json_bytes(&later_revision_before)?);
if later_manifest_before_sha256 != journal.manifest_after_sha256
|| later_revision_before_sha256 != journal.project_revision_after_sha256
|| current_manifest != &later_manifest_after
|| current_manifest_sha256 != later.manifest_after_sha256
|| current_revision != &later_revision_after
|| current_revision_sha256 != later.project_revision_after_sha256
|| current_revision.revision != later.target_project_revision
|| later_revision_after.revision != later.target_project_revision
|| later.target_project_revision != later.expected_project_revision.saturating_add(1)
|| final_image_state_for_journal(root, &later)? != AssetCanvasFinalImageState::Matches
{
continue;
}
let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.before.json",
&later.manifest_before_sha256,
)?;
let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.before.json",
later
.project_revision_before_sha256
.as_deref()
.ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?,
)?;
let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.after.json",
&later.manifest_after_sha256,
)?;
let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.after.json",
&later.project_revision_after_sha256,
)?;
if later_manifest_before != previous_manifest_after
|| later_revision_before != previous_revision_after
|| later_revision_after.revision != later.target_project_revision
|| later.target_project_revision
!= later.expected_project_revision.saturating_add(1)
|| final_image_state_for_journal(root, later)?
!= AssetCanvasFinalImageState::Matches
{
continue;
}
let matching_assets = current_manifest
.assets
.iter()
.filter(|asset| asset.id == later.asset_id)
.collect::<Vec<_>>();
if matching_assets.len() != 1 {
continue;
let reaches_current_state = current_manifest == &later_manifest_after
&& current_manifest_sha256 == later.manifest_after_sha256
&& current_revision == &later_revision_after
&& current_revision_sha256 == later.project_revision_after_sha256
&& current_revision.revision == later.target_project_revision;
if reaches_current_state && finalized_link {
let matching_assets = current_manifest
.assets
.iter()
.filter(|asset| asset.id == later.asset_id)
.collect::<Vec<_>>();
if matching_assets.len() == 1
&& matching_assets[0].local_path == later.final_image_relative_path
&& matching_assets[0].media_type == later.staged_image.media_type
{
return Ok(true);
}
continue;
}
visited.insert(later.commit_id.clone());
pending.push((later.clone(), later_manifest_after, later_revision_after));
}
let asset = matching_assets[0];
if asset.local_path != later.final_image_relative_path
|| asset.media_type != later.staged_image.media_type
{
continue;
}
return Ok(true);
}
Ok(false)
}
@@ -4460,6 +4472,17 @@ fn recover_asset_canvas_transaction_locked(
event,
));
}
if find_superseding_asset_canvas_commit(
root,
&journal,
&current_manifest,
&current_manifest_sha,
&current_revision,
&current_revision_sha,
)? {
return mark_asset_canvas_superseded_locked(root, journal, ledger)
.map(|outcome| (outcome, None));
}
return mark_asset_canvas_reconciliation_locked(root, journal, ledger)
.map(|outcome| (outcome, None));
}
@@ -213,6 +213,21 @@ pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityResult {
pub(crate) identity_scheme: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AcknowledgeAssetCanvasCandidateLayersInput {
pub(crate) project_path: String,
pub(crate) expected_project_id: String,
pub(crate) draft_id: String,
pub(crate) layer_ids: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AcknowledgeAssetCanvasCandidateLayersResult {
pub(crate) draft: AssetCanvasDraft,
}
pub(crate) struct RecoverAssetCanvasGenerationsExecution {
pub(crate) result: RecoverAssetCanvasGenerationsResult,
pub(crate) events: Vec<AssetCanvasCommittedEvent>,
@@ -1560,7 +1575,6 @@ fn persist_generation_candidate_at(
pub(crate) fn protect_candidate_layers_before_frontend_update_at(
root: &Path,
current_draft: &AssetCanvasDraft,
next_revision: u64,
incoming_canvas: &AssetCanvasState,
) -> Result<AssetCanvasState, String> {
let mut merged_canvas = incoming_canvas.clone();
@@ -1568,7 +1582,7 @@ pub(crate) fn protect_candidate_layers_before_frontend_update_at(
if record.phase != AssetCanvasGenerationStatus::CandidateReady {
continue;
}
let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else {
let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else {
continue;
};
if ledger.project_id != current_draft.project_id
@@ -1589,20 +1603,74 @@ pub(crate) fn protect_candidate_layers_before_frontend_update_at(
else {
continue;
};
if merged_canvas
if !merged_canvas
.layers
.iter()
.any(|layer| layer.layer_id == layer_id)
{
ledger.candidate_layer_acknowledged_revision = Some(next_revision);
write_generation_ledger(root, &mut ledger)?;
} else {
merged_canvas.layers.push(candidate_layer.clone());
}
}
Ok(merged_canvas)
}
pub(crate) fn acknowledge_candidate_layers_at(
root: &Path,
input: &AcknowledgeAssetCanvasCandidateLayersInput,
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
validate_uuid_v4(&input.draft_id, "draftId")?;
if input.layer_ids.len() > ASSET_CANVAS_MAX_LAYERS {
return Err("候选图层确认数量已达上限".to_string());
}
for layer_id in &input.layer_ids {
validate_plain_component(layer_id, "layerId", 512)?;
}
validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
if draft.project_id != manifest.project_id || draft.draft_id != input.draft_id {
return Err("素材画布候选确认身份不一致".to_string());
}
let requested_layer_ids = input
.layer_ids
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
let authoritative_layer_ids = draft
.canvas
.layers
.iter()
.map(|layer| layer.layer_id.as_str())
.collect::<HashSet<_>>();
for record in &draft.generations {
if record.phase != AssetCanvasGenerationStatus::CandidateReady {
continue;
}
let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else {
continue;
};
if ledger.project_id != draft.project_id
|| ledger.draft_id != draft.draft_id
|| ledger.generation_id != record.generation_id
|| ledger.candidate_layer_acknowledged_revision.is_some()
{
continue;
}
let Some(layer_id) = ledger.candidate_layer_id.as_deref() else {
continue;
};
if requested_layer_ids.contains(&layer_id) && authoritative_layer_ids.contains(layer_id) {
ledger.candidate_layer_acknowledged_revision = Some(draft.revision);
write_generation_ledger(root, &mut ledger)?;
}
}
Ok(AcknowledgeAssetCanvasCandidateLayersResult { draft })
}
pub(crate) fn restore_unacknowledged_candidate_layers_at(
root: &Path,
expected_project_id: &str,
@@ -5285,29 +5353,66 @@ mod tests {
.iter()
.any(|layer| layer.layer_id == candidate_layer_id));
let acknowledged = update_asset_canvas_draft_at(
let still_unacknowledged = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read protected candidate ledger")
.expect("protected candidate ledger retained");
assert_eq!(
still_unacknowledged.candidate_layer_acknowledged_revision,
None
);
let ignored_acknowledgement = acknowledge_candidate_layers_at(
directory.path(),
&UpdateAssetCanvasDraftInput {
&AcknowledgeAssetCanvasCandidateLayersInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
expected_draft_revision: protected.revision,
status: AssetCanvasDraftStatus::Editing,
canvas: protected.canvas.clone(),
generations: protected.generations.clone(),
layer_ids: vec![source_layer_id.clone(), "unknown-layer".to_string()],
},
)
.expect("acknowledge candidate")
.draft;
.expect("ignore unknown and non-candidate layer acknowledgement");
assert_eq!(ignored_acknowledgement.draft.revision, protected.revision);
let ignored_ledger = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read ignored acknowledgement ledger")
.expect("ignored acknowledgement ledger retained");
assert_eq!(ignored_ledger.candidate_layer_acknowledged_revision, None);
let acknowledged = acknowledge_candidate_layers_at(
directory.path(),
&AcknowledgeAssetCanvasCandidateLayersInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
layer_ids: vec![candidate_layer_id.to_string()],
},
)
.expect("acknowledge candidate");
assert_eq!(acknowledged.draft.revision, protected.revision);
let acknowledged_ledger = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read acknowledged ledger")
.expect("acknowledged ledger retained");
assert_eq!(
acknowledged_ledger.candidate_layer_acknowledged_revision,
Some(acknowledged.revision)
Some(protected.revision)
);
let mut intentional_delete_canvas = acknowledged.canvas.clone();
let repeated = acknowledge_candidate_layers_at(
directory.path(),
&AcknowledgeAssetCanvasCandidateLayersInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
layer_ids: vec![candidate_layer_id.to_string()],
},
)
.expect("repeat candidate acknowledgement");
assert_eq!(repeated.draft.revision, protected.revision);
let repeated_ledger = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read repeated acknowledgement ledger")
.expect("repeated acknowledgement ledger retained");
assert_eq!(repeated_ledger, acknowledged_ledger);
let mut intentional_delete_canvas = acknowledged.draft.canvas.clone();
intentional_delete_canvas
.layers
.retain(|layer| layer.layer_id != candidate_layer_id);
@@ -5319,10 +5424,10 @@ mod tests {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
expected_draft_revision: acknowledged.revision,
expected_draft_revision: acknowledged.draft.revision,
status: AssetCanvasDraftStatus::Editing,
canvas: intentional_delete_canvas,
generations: acknowledged.generations.clone(),
generations: acknowledged.draft.generations.clone(),
},
)
.expect("allow intentional candidate deletion after acknowledgement")
@@ -188,7 +188,7 @@ fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) {
&fixture,
&draft,
&staged,
Uuid::new_v4().to_string(),
"f0000000-0000-4000-8000-000000000001".to_string(),
Uuid::new_v4().to_string(),
);
first_input.source_layer_id = Some(first_layer_id.clone());
@@ -207,7 +207,7 @@ fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) {
&fixture,
&draft_before_second,
&staged,
Uuid::new_v4().to_string(),
"10000000-0000-4000-8000-000000000002".to_string(),
Uuid::new_v4().to_string(),
);
second_input.source_layer_id = Some(second_layer_id);
@@ -1211,6 +1211,124 @@ fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit(
commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery");
}
#[test]
fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit() {
let (fixture, first_commit_id, second_commit_id) =
initialize_refine_fixture_with_later_commit();
// initialize_refine_fixture_with_later_commit only forces the first
// transaction into the unresolved state. Restore both completed ledgers so
// this test models two successful commits followed by a restart.
for commit_id in [&first_commit_id, &second_commit_id] {
let mut journal = read_asset_canvas_journal(fixture.root(), commit_id)
.expect("read completed transaction journal")
.expect("completed transaction journal");
let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id)
.expect("read completed transaction ledger")
.expect("completed transaction ledger");
journal.stage = AssetCanvasTransactionStage::Committed;
ledger.status = AssetCanvasLedgerStatus::Committed;
write_asset_canvas_journal(fixture.root(), &journal)
.expect("restore completed journal stage");
write_asset_canvas_ledger(fixture.root(), &ledger)
.expect("restore completed ledger status");
}
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover two completed refine transactions");
let first_outcome = recovered
.result
.outcomes
.iter()
.find(|outcome| outcome.commit_id == first_commit_id)
.expect("first completed outcome");
let second_outcome = recovered
.result
.outcomes
.iter()
.find(|outcome| outcome.commit_id == second_commit_id)
.expect("second completed outcome");
assert_eq!(
first_outcome.status,
RecoverAssetCanvasOutcomeStatus::Superseded
);
assert_eq!(
second_outcome.status,
RecoverAssetCanvasOutcomeStatus::AlreadyCommitted
);
assert!(!recovered.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("repeat recovery after supersede");
assert!(repeated.result.outcomes.iter().any(|outcome| {
outcome.commit_id == first_commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded
}));
assert!(!repeated.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
}
#[test]
fn recovery_follows_verified_refine_commit_chain_to_current_state() {
let (fixture, first_commit_id, second_commit_id) =
initialize_refine_fixture_with_later_commit();
let mut first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id)
.expect("read first transaction before third commit")
.expect("first transaction retained");
let mut first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id)
.expect("read first ledger before third commit")
.expect("first ledger retained");
first_journal.stage = AssetCanvasTransactionStage::Committed;
first_ledger.status = AssetCanvasLedgerStatus::Committed;
write_asset_canvas_journal(fixture.root(), &first_journal)
.expect("restore first journal before third commit");
write_asset_canvas_ledger(fixture.root(), &first_ledger)
.expect("restore first ledger before third commit");
let draft_after_second =
read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id)
.expect("read draft after second commit")
.expect("draft retained after second commit");
let (draft_before_third, third_layer_id, third_media_sha256) =
add_candidate_layer(&fixture, &draft_after_second);
let staged = stage_image(&fixture, &draft_before_third);
let mut third_input = commit_input(
&fixture,
&draft_before_third,
&staged,
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
third_input.source_layer_id = Some(third_layer_id);
third_input.media_sha256 = Some(third_media_sha256);
let third_commit_id = third_input.commit_id.clone();
commit_asset_canvas_at(fixture.root(), &third_input).expect("commit third final image");
force_refine_transaction_unresolved(&fixture, &first_commit_id);
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover three chained refine commits");
for commit_id in [&first_commit_id, &second_commit_id] {
assert!(recovered.result.outcomes.iter().any(|outcome| {
outcome.commit_id == *commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded
}));
}
assert!(recovered.result.outcomes.iter().any(|outcome| {
outcome.commit_id == third_commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::AlreadyCommitted
}));
assert!(!recovered.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("repeat chained refine recovery");
assert!(!repeated.result.outcomes.iter().any(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
}
#[test]
fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() {
let (fixture, first_commit_id, second_commit_id) =
@@ -12,7 +12,8 @@ const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layo
const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096;
const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512;
const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000;
const RESOURCE_LAYOUT_MIN_COORDINATE: i32 = -1_000_000;
const RESOURCE_LAYOUT_MAX_COORDINATE: i32 = 1_000_000;
const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100;
const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10;
@@ -337,11 +338,13 @@ fn validate_resource_layout_positions(
if !resource_ids.insert(resource_id) {
return Err("资源布局 resourceId 不能重复".to_string());
}
if position.x > RESOURCE_LAYOUT_MAX_COORDINATE
if position.x < RESOURCE_LAYOUT_MIN_COORDINATE
|| position.x > RESOURCE_LAYOUT_MAX_COORDINATE
|| position.y < RESOURCE_LAYOUT_MIN_COORDINATE
|| position.y > RESOURCE_LAYOUT_MAX_COORDINATE
{
return Err(format!(
"资源布局坐标不能超过 {RESOURCE_LAYOUT_MAX_COORDINATE}"
"资源布局坐标必须在 {RESOURCE_LAYOUT_MIN_COORDINATE} {RESOURCE_LAYOUT_MAX_COORDINATE} 之间"
));
}
}
@@ -526,7 +529,7 @@ mod tests {
))
}
fn layout_position(id: &str, x: u32) -> ProjectResourceCanvasPosition {
fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition {
ProjectResourceCanvasPosition {
resource_id: id.to_string(),
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art,
@@ -883,11 +886,27 @@ mod tests {
RESOURCE_LAYOUT_MAX_COORDINATE
);
let coordinate_error = update_project_resource_canvas_layout_at(
let lower_boundary = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
boundary.layout.revision,
vec![layout_position(
"asset-coordinate-lower-boundary",
RESOURCE_LAYOUT_MIN_COORDINATE,
)],
)
.expect("lower boundary coordinate must remain valid");
assert_eq!(
lower_boundary.layout.positions[0].x,
RESOURCE_LAYOUT_MIN_COORDINATE
);
let coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
lower_boundary.layout.revision,
vec![layout_position(
"asset-coordinate",
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
@@ -895,6 +914,18 @@ mod tests {
)
.expect_err("oversized coordinate must fail");
assert!(coordinate_error.contains("坐标"));
let lower_coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
lower_boundary.layout.revision,
vec![layout_position(
"asset-coordinate-below-lower-boundary",
RESOURCE_LAYOUT_MIN_COORDINATE - 1,
)],
)
.expect_err("coordinate below lower boundary must fail");
assert!(lower_coordinate_error.contains("坐标"));
let id_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
@@ -843,6 +843,7 @@ export function AssetCanvasSurface({
const dragRef = useRef<DragState | null>(null);
const suppressNextLayerClickRef = useRef(false);
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const pendingCandidateLayerIdsRef = useRef(new Set<string>());
const savePromiseRef = useRef<Promise<void> | null>(null);
const hostRevisionRef = useRef(expectedHostRevision);
const previewUrlsRef = useRef(new Set<string>());
@@ -1172,6 +1173,7 @@ export function AssetCanvasSurface({
const epoch = epochRef.current + 1;
epochRef.current = epoch;
saveQueueRef.current = Promise.resolve();
pendingCandidateLayerIdsRef.current.clear();
savePromiseRef.current = null;
pendingCommitRef.current = null;
pendingGenerationRef.current = null;
@@ -1267,6 +1269,28 @@ export function AssetCanvasSurface({
}
await hydrateDraft(nextDraft, epoch);
if (epoch !== epochRef.current) return;
if (
nextDraft.generations.some(
(generation) => generation.phase === 'candidate-ready',
)
) {
const acknowledged = await host.project.acknowledgeCandidateLayers({
scope: stableScope,
// The host ignores non-candidate and already acknowledged IDs. This
// closes the restart window without exposing private ledger state.
layerIds: nextDraft.canvas.layers.map((layer) => layer.layerId),
});
if (epoch !== epochRef.current) return;
if (acknowledged.status !== 'ok') {
throw new Error(
acknowledged.status === 'failed' ||
acknowledged.status === 'unsupported-capability'
? acknowledged.message
: '恢复候选图层确认发生草稿冲突',
);
}
applyDraftCandidate(acknowledged.value);
}
// 生成 operation 的恢复可能持续数分钟。画布草稿已安全 hydrate 后应立即可编辑,
// 后台恢复只更新任务投影,不能把整个画布继续锁在 recovering。
@@ -1350,6 +1374,7 @@ export function AssetCanvasSurface({
previewUrls.clear();
};
}, [
applyDraftCandidate,
applyGenerationProgressRevision,
expectedHostRevision,
host,
@@ -1463,13 +1488,64 @@ export function AssetCanvasSurface({
return () => observer.disconnect();
}, [draft]);
const persistDraft =
useCallback(async (): Promise<ImageCanvasDraft | null> => {
const acknowledgePendingCandidateLayers = useCallback(
async (epoch: number): Promise<ImageCanvasDraft | null> => {
const currentDraft = draftRef.current;
const layerIds = [...pendingCandidateLayerIdsRef.current];
if (!currentDraft || epoch !== epochRef.current) return null;
if (layerIds.length === 0) return currentDraft;
const result = await host.project.acknowledgeCandidateLayers({
scope: stableScope,
layerIds,
});
if (epoch !== epochRef.current) return null;
if (result.status !== 'ok') {
setLifecycle({
kind: 'canvas.failed',
operation: 'draft-save',
code: result.status === 'failed' ? result.code : result.status,
message:
result.status === 'failed' ||
result.status === 'unsupported-capability'
? result.message
: '候选图层确认发生草稿冲突',
reconciliationRequired: false,
});
return null;
}
for (const layerId of layerIds) {
pendingCandidateLayerIdsRef.current.delete(layerId);
}
applyDraftCandidate(result.value);
return result.value;
},
[applyDraftCandidate, host.project, stableScope],
);
const enqueuePendingCandidateAcknowledgements = useCallback(async () => {
const epoch = epochRef.current;
const task = saveQueueRef.current.then(() =>
acknowledgePendingCandidateLayers(epoch),
);
saveQueueRef.current = task.catch(() => undefined);
return await task;
}, [acknowledgePendingCandidateLayers]);
const persistDraft = useCallback(
async (
candidateLayerIds: readonly string[] = [],
): Promise<ImageCanvasDraft | null> => {
const epoch = epochRef.current;
const requestedVersion = documentVersionRef.current;
const task = saveQueueRef.current.then(async () => {
const currentDraft = draftRef.current;
if (!currentDraft || epoch !== epochRef.current) return null;
// Register a candidate only when its own save reaches the head of the
// FIFO. Registering at enqueue time would let an older autosave
// acknowledge it before this save has written the latest canvas.
for (const layerId of candidateLayerIds) {
pendingCandidateLayerIdsRef.current.add(layerId);
}
const result = await host.project.updateDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
@@ -1509,17 +1585,42 @@ export function AssetCanvasSurface({
persistedDocumentVersionRef.current,
requestedVersion,
);
const acknowledged = await acknowledgePendingCandidateLayers(epoch);
if (!acknowledged) return null;
if (
requestedVersion === documentVersionRef.current &&
lifecycleRef.current.kind === 'canvas.editing'
) {
setLifecycle({ kind: 'canvas.editing', dirty: false });
}
return result.value;
return acknowledged;
});
saveQueueRef.current = task.catch(() => undefined);
return await task;
}, [applyDraftCandidate, host.project, stableScope]);
},
[
acknowledgePendingCandidateLayers,
applyDraftCandidate,
host.project,
stableScope,
],
);
const flushDraftPersistence = useCallback(
async ({ persistDirty = true }: { persistDirty?: boolean } = {}) => {
const epoch = epochRef.current;
await saveQueueRef.current;
if (epoch !== epochRef.current || !draftRef.current) return null;
if (
persistDirty &&
documentVersionRef.current !== persistedDocumentVersionRef.current
) {
return await persistDraft();
}
return await enqueuePendingCandidateAcknowledgements();
},
[enqueuePendingCandidateAcknowledgements, persistDraft],
);
useEffect(() => {
if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) {
@@ -1697,13 +1798,7 @@ export function AssetCanvasSurface({
) {
return;
}
if (
documentVersionRef.current !== persistedDocumentVersionRef.current &&
!(await persistDraft())
) {
return;
}
const currentDraft = draftRef.current;
const currentDraft = await flushDraftPersistence();
if (!currentDraft || lifecycleRef.current.kind !== 'canvas.editing') return;
const epoch = epochRef.current;
const historySnapshot = getCanvasHistorySnapshot();
@@ -1809,7 +1904,7 @@ export function AssetCanvasSurface({
captureHistory,
getCanvasHistorySnapshot,
host,
persistDraft,
flushDraftPersistence,
stableScope,
]);
@@ -1817,11 +1912,10 @@ export function AssetCanvasSurface({
async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
const currentDraft = draftRef.current;
if (
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current ||
!currentDraft ||
!draftRef.current ||
!files.length
)
return;
@@ -1854,6 +1948,15 @@ export function AssetCanvasSurface({
) {
return;
}
const currentDraft = await flushDraftPersistence();
if (
!currentDraft ||
epoch !== epochRef.current ||
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current
) {
return;
}
const imported = await host.asset.importImages({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
@@ -1927,7 +2030,7 @@ export function AssetCanvasSurface({
markDirty();
setNotice(`已导入 ${additions.length} 张图片`);
},
[captureHistory, host.asset, markDirty, stableScope],
[captureHistory, flushDraftPersistence, host.asset, markDirty, stableScope],
);
const deleteCanvasLayer = useCallback(
@@ -2024,12 +2127,11 @@ export function AssetCanvasSurface({
});
const task = (async () => {
const epoch = saveEpoch;
if (!draftRef.current) return;
if (documentVersionRef.current !== persistedDocumentVersionRef.current) {
setLifecycle({ kind: 'canvas.saving', stage: 'draft' });
const persisted = await persistDraft();
if (!persisted) return;
}
const persisted = await flushDraftPersistence();
if (!persisted) return;
if (epoch !== epochRef.current || !draftRef.current) return;
setLifecycle({ kind: 'canvas.saving', stage: 'committing' });
const referenceResourceIds = draftRef.current.sourceResourceId
@@ -2156,7 +2258,7 @@ export function AssetCanvasSurface({
hydrateDraft,
onCommitted,
onSaveAttempt,
persistDraft,
flushDraftPersistence,
quickEditSourceLayerId,
renderImage,
sessionId,
@@ -2164,9 +2266,8 @@ export function AssetCanvasSurface({
]);
const discardCanvas = useCallback(() => {
const currentDraft = draftRef.current;
if (
!currentDraft ||
!draftRef.current ||
!['canvas.editing', 'canvas.failed'].includes(
lifecycleRef.current.kind,
) ||
@@ -2178,13 +2279,16 @@ export function AssetCanvasSurface({
}
setExitActionPending(true);
const epoch = epochRef.current;
void host.project
.discardDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
void flushDraftPersistence({ persistDirty: false })
.then((currentDraft) => {
if (!currentDraft || epoch !== epochRef.current) return null;
return host.project.discardDraft({
scope: stableScope,
expectedDraftRevision: currentDraft.revision,
});
})
.then((result) => {
if (epoch !== epochRef.current) return;
if (!result || epoch !== epochRef.current) return;
if (result.status === 'ok') {
setExitDialogOpen(false);
onCancel?.({
@@ -2219,7 +2323,7 @@ export function AssetCanvasSurface({
.finally(() => {
if (epoch === epochRef.current) setExitActionPending(false);
});
}, [host.project, onCancel, stableScope]);
}, [flushDraftPersistence, host.project, onCancel, stableScope]);
const keepDraftAndExit = useCallback(() => {
if (
@@ -2237,7 +2341,7 @@ export function AssetCanvasSurface({
if (!currentDraft) return;
setExitActionPending(true);
const epoch = epochRef.current;
void persistDraft()
void flushDraftPersistence()
.then((persisted) => {
if (epoch !== epochRef.current || !persisted) return;
setExitDialogOpen(false);
@@ -2249,7 +2353,7 @@ export function AssetCanvasSurface({
.finally(() => {
if (epoch === epochRef.current) setExitActionPending(false);
});
}, [exitActionPending, onCancel, persistDraft, stableScope.draftId]);
}, [exitActionPending, flushDraftPersistence, onCancel, stableScope.draftId]);
const requestCanvasExit = useCallback(() => {
const currentDraft = draftRef.current;
@@ -2586,15 +2690,17 @@ export function AssetCanvasSurface({
return true;
};
const task = (async () => {
if (needsDraftPersist) {
const persisted = await persistDraft();
if (!persisted) {
const persisted = needsDraftPersist
? await persistDraft()
: await flushDraftPersistence();
if (!persisted) {
if (needsDraftPersist) {
await persistGenerationFailure(
'draft-save-failed',
'生成任务占位保存失败',
);
return;
}
return;
}
const currentDraft = draftRef.current;
if (
@@ -2755,10 +2861,16 @@ export function AssetCanvasSurface({
setGenerationTasks(
mergedGenerations.map(runtimeGenerationTaskFromRecord),
);
// Acknowledge the authoritative candidate with a canvas save. Until
// this first save succeeds, the backend keeps merging the new layer
// back into stale autosaves that were queued while generation ran.
if (!(await persistDraft())) return;
// Save the authoritative candidate, then acknowledge its private ledger
// in the same FIFO. Until both steps succeed, the backend keeps merging
// the new layer back into stale autosaves that were queued during generation.
if (
!(await persistDraft(
authoritativeLayers.map((layer) => layer.layerId),
))
) {
return;
}
pendingGenerationRef.current = null;
persistedDocumentVersionRef.current = documentVersionRef.current;
setLifecycle({ kind: 'canvas.editing', dirty: false });
@@ -2793,6 +2905,7 @@ export function AssetCanvasSurface({
generationPrompt,
generationReferenceResourceIds,
generationTasks,
flushDraftPersistence,
host.generation,
onSaveAttempt,
onWalletBalanceMayHaveChanged,
@@ -2815,13 +2928,7 @@ export function AssetCanvasSurface({
) {
return;
}
if (
documentVersionRef.current !== persistedDocumentVersionRef.current &&
!(await persistDraft())
) {
return;
}
const currentDraft = draftRef.current;
const currentDraft = await flushDraftPersistence();
if (!currentDraft) return;
const epoch = epochRef.current;
setArchivingGenerationIds((current) => [...current, generationId]);
@@ -2857,9 +2964,9 @@ export function AssetCanvasSurface({
},
[
archivingGenerationIds,
flushDraftPersistence,
generationTasks,
host.generation,
persistDraft,
stableScope,
],
);
@@ -9,6 +9,7 @@ import type {
ImageCanvasHostScope,
ImageCanvasLocalImportResult,
ImageCanvasMediaRef,
ImageCanvasProjectPort,
} from '@genarrative/image-canvas-core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
@@ -44,6 +45,10 @@ type DraftUpdateResult = {
draft: ImageCanvasDraft;
};
type CandidateLayerAcknowledgementResult = {
draft: ImageCanvasDraft;
};
type StoredMediaResult = {
status: 'stored' | 'conflict';
draftId: string;
@@ -144,8 +149,12 @@ export type LocalAssetCommittedEvent = {
occurredAt: number;
};
export type TauriImageCanvasHostAdapter = ImageCanvasHostPort & {
export type TauriImageCanvasHostAdapter = Omit<
ImageCanvasHostPort,
'project'
> & {
readonly kind: 'tauri';
readonly project: ImageCanvasProjectPort;
readonly projectPath: string;
readonly expectedProjectId: string;
readMediaPreview(input: {
@@ -545,6 +554,20 @@ export function createTauriImageCanvasHostAdapter(input: {
return failure(error);
}
},
async acknowledgeCandidateLayers(acknowledgement) {
try {
const result = await invokeInput<CandidateLayerAcknowledgementResult>(
'acknowledge_local_project_asset_canvas_candidate_layers',
{
...baseScope(acknowledgement.scope),
layerIds: acknowledgement.layerIds,
},
);
return { status: 'ok', value: result.draft };
} catch (error) {
return failure(error);
}
},
async discardDraft(update) {
try {
const result = await invokeInput<DraftUpdateResult>(
+277 -1
View File
@@ -5059,6 +5059,16 @@ iframe.preview-frame {
box-shadow: none;
}
.game-workbench-stage > .game-resource-manager {
grid-row: 3;
min-height: 0;
}
.game-workbench-chat {
border-color: var(--platform-line-soft);
box-shadow: var(--platform-nav-active-shadow);
}
.game-workbench-stage[data-resource-view-state^='resources.asset-canvas'],
.game-workbench-stage[data-resource-view-state='resources.ui-editor'] {
grid-template-rows: auto minmax(0, 1fr);
@@ -5205,6 +5215,7 @@ iframe.preview-frame {
.game-resource-manager {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
@@ -5281,6 +5292,8 @@ iframe.preview-frame {
.game-resource-canvas {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding: 12px;
overflow: auto;
@@ -5301,6 +5314,17 @@ iframe.preview-frame {
cursor: grabbing;
}
.game-resource-canvas--paged {
overflow: hidden;
cursor: grab;
overscroll-behavior: none;
touch-action: none;
}
.game-resource-canvas--paged:active {
cursor: grabbing;
}
.game-resource-canvas-content {
position: relative;
display: grid;
@@ -5316,6 +5340,246 @@ iframe.preview-frame {
min-height: 0;
}
.game-resource-canvas-content--paged {
display: block;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.game-resource-page-shell {
position: relative;
width: 100%;
height: 100%;
}
.game-resource-outline {
position: absolute;
top: 50%;
left: 12px;
z-index: 4;
display: flex;
flex-direction: column;
gap: 4px;
width: max-content;
max-width: 180px;
max-height: calc(100% - 24px);
padding: 6px;
overflow: auto;
border: 1px solid transparent;
border-radius: 14px;
background: transparent;
box-shadow: none;
opacity: 0.68;
transform: translateY(-50%) scale(0.86);
transform-origin: left center;
transition:
border-color 160ms ease,
opacity 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
backdrop-filter: none;
}
.game-resource-outline:hover,
.game-resource-outline:has(> button:focus-visible) {
border-color: var(--platform-nav-active-border);
background: var(--platform-nav-fill);
box-shadow: var(--platform-nav-active-shadow);
opacity: 1;
transform: translateY(-50%) scale(1);
backdrop-filter: blur(10px);
}
.game-resource-outline button {
display: grid;
grid-template-columns: 0 minmax(0, 1fr);
align-items: center;
gap: 0;
min-height: 34px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--platform-nav-item-text);
cursor: pointer;
text-align: left;
transition:
grid-template-columns 160ms ease,
gap 160ms ease;
}
.game-resource-outline:hover button,
.game-resource-outline:has(> button:focus-visible) button {
grid-template-columns: 18px minmax(0, 1fr);
gap: 6px;
}
.game-resource-outline button svg {
width: 0;
min-width: 0;
opacity: 0;
transform: scale(0.65);
transition:
width 160ms ease,
opacity 120ms ease,
transform 160ms ease;
}
.game-resource-outline:hover button svg,
.game-resource-outline:has(> button:focus-visible) button svg {
width: 14px;
opacity: 1;
transform: scale(1);
}
.game-resource-outline button:hover,
.game-resource-outline button:focus-visible {
background: var(--platform-nav-item-hover-fill);
outline: 0;
}
.game-resource-outline button.is-active {
color: var(--platform-nav-item-text-active);
}
.game-resource-outline:hover button.is-active,
.game-resource-outline:has(> button:focus-visible) button.is-active {
border-color: var(--platform-nav-active-border);
background: var(--platform-nav-active-fill);
box-shadow: var(--platform-nav-active-shadow);
}
.game-resource-outline button strong {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 10px;
height: 100%;
min-width: 0;
min-height: 0;
}
.game-resource-page-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 36px;
padding: 0 4px;
}
.game-resource-page-heading span {
display: inline-flex;
align-items: center;
gap: 8px;
color: #6d4b3f;
font-size: 13px;
}
.game-resource-page-heading strong {
font-size: 15px;
}
.game-resource-page-heading small {
color: #a98a7c;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.game-resource-page-canvas {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid #ecdfd8;
border-radius: 12px;
background:
radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
#fffdfa;
}
.game-resource-canvas-reset {
position: absolute;
top: 12px;
right: 12px;
z-index: 3;
display: grid;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid rgb(227 207 197 / 88%);
border-radius: 8px;
background: rgb(255 253 250 / 88%);
color: #8c6252;
cursor: pointer;
place-items: center;
box-shadow: 0 4px 12px rgb(112 70 52 / 10%);
backdrop-filter: blur(4px);
}
.game-resource-canvas-reset:hover,
.game-resource-canvas-reset:focus-visible {
border-color: #ce7650;
color: #bd5f37;
outline: 2px solid rgb(206 118 80 / 22%);
outline-offset: 1px;
}
.game-resource-page-world {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
}
.game-resource-page-empty {
position: absolute;
inset: 0;
display: grid;
padding: 24px;
color: #b0958a;
font-size: 12px;
place-items: center;
pointer-events: none;
}
.game-resource-next-page {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 38px;
padding: 0 14px;
border: 1px solid #e8d6cc;
border-radius: 10px;
background: #fff8f4;
color: #725348;
cursor: pointer;
}
.game-resource-next-page:hover,
.game-resource-next-page:focus-visible {
border-color: #d6946f;
color: #a9502a;
outline: 0;
}
.game-resource-next-page span {
color: #a98a7c;
font-size: 10px;
}
.game-resource-next-page strong {
font-size: 12px;
}
.game-resource-section-stack {
display: contents;
}
@@ -5574,6 +5838,18 @@ iframe.preview-frame {
transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0);
}
.game-resource-card {
cursor: grab;
touch-action: none;
}
.game-resource-card.is-dragging {
cursor: grabbing;
box-shadow:
0 14px 32px rgb(195 105 62 / 24%),
0 0 0 2px rgb(216 115 66 / 24%);
}
.game-resource-card:hover,
.game-resource-card:focus-within,
.game-resource-card.is-selected {
@@ -5834,7 +6110,7 @@ iframe.preview-frame {
flex: 0 0 auto;
}
.game-resource-focus-actions > button:first-child {
.game-resource-focus-action {
min-height: 32px;
padding: 0 14px;
border: 1px solid #d78d69;
@@ -41,6 +41,7 @@ export type ResourceDependencyOverlayProps = {
section?: ProjectResourceCanvasSection;
visibleResourceIds: ReadonlySet<string>;
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId;
canvasViewport?: Readonly<Point & { scale: number }>;
geometryRevision?: string;
};
@@ -460,6 +461,7 @@ export const ResourceDependencyOverlay = forwardRef<
ResourceDependencyOverlayProps
>(function ResourceDependencyOverlay(
{
canvasViewport,
cardSizeByResourceId,
geometryRevision = '',
graph,
@@ -492,10 +494,6 @@ export const ResourceDependencyOverlay = forwardRef<
useLayoutEffect(() => {
const plane = overlayRef.current?.parentElement;
const viewport = plane?.closest<HTMLElement>(SECTION_SCROLL_SELECTOR);
if (plane?.closest('.game-resource-canvas--dependency')) {
setLogicalViewport(null);
return undefined;
}
if (!plane || !viewport) {
setLogicalViewport(null);
return undefined;
@@ -504,16 +502,26 @@ export const ResourceDependencyOverlay = forwardRef<
const measure = () => {
frameId = null;
const parsedScale = Number(plane.dataset.resourceSectionScale);
const scale =
const legacyScale =
Number.isFinite(parsedScale) && parsedScale > 0 ? parsedScale : 1;
const transformedViewport = plane.closest(
'.game-resource-canvas--dependency',
)
? canvasViewport
: undefined;
const scale = transformedViewport?.scale ?? legacyScale;
const viewportRect = viewport.getBoundingClientRect();
const width = viewport.clientWidth || viewportRect.width;
const height = viewport.clientHeight || viewportRect.height;
const next =
width > 0 && height > 0
? {
x: viewport.scrollLeft / scale,
y: viewport.scrollTop / scale,
x: transformedViewport
? -transformedViewport.x / transformedViewport.scale
: viewport.scrollLeft / scale,
y: transformedViewport
? -transformedViewport.y / transformedViewport.scale
: viewport.scrollTop / scale,
width: width / scale,
height: height / scale,
}
@@ -545,7 +553,7 @@ export const ResourceDependencyOverlay = forwardRef<
viewport.removeEventListener('scroll', scheduleMeasure);
window.removeEventListener('resize', scheduleMeasure);
};
}, [geometryRevision]);
}, [canvasViewport, geometryRevision]);
const rectByResourceId = useMemo(() => {
const result = new Map<string, Rect>();
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import {
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
type ProjectResourceCanvasLayout,
type ProjectResourceCanvasLayoutMode,
@@ -29,15 +30,14 @@ export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
export const RESOURCE_CANVAS_BOUNDARY_PADDING = 96;
/**
* Initial fit inset only. The resource canvas background itself is infinite;
* this value must not be used to clamp a later pan or zoom.
*/
export const RESOURCE_CANVAS_FIT_PADDING = 16;
const sectionOrder: ProjectResourceCanvasSection[] = [
'code',
'document',
'version',
'art',
'audio',
];
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
['document', 'art', 'audio', 'code', 'version'];
export type ResourceCanvasCardSize = {
width: number;
@@ -65,76 +65,24 @@ function clampResourceCanvasNumber(
}
/**
* Keeps the dependency canvas inside the invisible boundary formed by every
* resource plus a fixed screen-space safety margin. The minimum zoom is the
* scale at which the whole boundary fits; panning never lets the resource
* union move beyond the safety margin on either axis.
* Normalizes an infinite resource-canvas viewport.
*
* The canvas has no navigational edge: x/y are intentionally left untouched
* so users can pan the background and resources arbitrarily far in any
* direction. Only invalid coordinates and the shared image-canvas scale
* limits are normalized here.
*/
export function constrainResourceCanvasViewport({
bounds,
canvasSize,
padding = RESOURCE_CANVAS_BOUNDARY_PADDING,
viewport,
}: {
bounds: ResourceCanvasNavigationBounds;
canvasSize: { width: number; height: number };
padding?: number;
viewport: CanvasViewport;
}): CanvasViewport {
const canvasWidth = Math.max(
1,
Number.isFinite(canvasSize.width) ? canvasSize.width : 1,
);
const canvasHeight = Math.max(
1,
Number.isFinite(canvasSize.height) ? canvasSize.height : 1,
);
const boundsX = Number.isFinite(bounds.x) ? bounds.x : 0;
const boundsY = Number.isFinite(bounds.y) ? bounds.y : 0;
const boundsWidth = Math.max(
1,
Number.isFinite(bounds.width) ? bounds.width : 1,
);
const boundsHeight = Math.max(
1,
Number.isFinite(bounds.height) ? bounds.height : 1,
);
const normalizedPadding = Math.max(0, Number.isFinite(padding) ? padding : 0);
const horizontalPadding = Math.min(normalizedPadding, (canvasWidth - 1) / 2);
const verticalPadding = Math.min(normalizedPadding, (canvasHeight - 1) / 2);
const availableWidth = Math.max(1, canvasWidth - horizontalPadding * 2);
const availableHeight = Math.max(1, canvasHeight - verticalPadding * 2);
const minimumScale = clampResourceCanvasNumber(
Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight),
MIN_SCALE,
MAX_SCALE,
);
const scale = clampResourceCanvasNumber(
Number.isFinite(viewport.scale) ? viewport.scale : minimumScale,
minimumScale,
MAX_SCALE,
);
const minimumX =
canvasWidth - horizontalPadding - (boundsX + boundsWidth) * scale;
const maximumX = horizontalPadding - boundsX * scale;
const minimumY =
canvasHeight - verticalPadding - (boundsY + boundsHeight) * scale;
const maximumY = verticalPadding - boundsY * scale;
const centeredX = canvasWidth / 2 - (boundsX + boundsWidth / 2) * scale;
const centeredY = canvasHeight / 2 - (boundsY + boundsHeight / 2) * scale;
const candidateX = Number.isFinite(viewport.x) ? viewport.x : centeredX;
const candidateY = Number.isFinite(viewport.y) ? viewport.y : centeredY;
export function normalizeInfiniteResourceCanvasViewport(
viewport: CanvasViewport,
): CanvasViewport {
return {
x:
minimumX <= maximumX
? clampResourceCanvasNumber(candidateX, minimumX, maximumX)
: centeredX,
y:
minimumY <= maximumY
? clampResourceCanvasNumber(candidateY, minimumY, maximumY)
: centeredY,
scale,
x: Number.isFinite(viewport.x) ? viewport.x : 0,
y: Number.isFinite(viewport.y) ? viewport.y : 0,
scale: clampResourceCanvasNumber(
Number.isFinite(viewport.scale) ? viewport.scale : 1,
MIN_SCALE,
MAX_SCALE,
),
};
}
@@ -1185,7 +1133,7 @@ export function reconcileResourceCanvasLayout(
resources.map((resource) => [resource.id, resource]),
);
const positionsBySection = new Map(
sectionOrder.map((section) => [
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
section,
[] as ProjectResourceCanvasPosition[],
]),
@@ -1202,7 +1150,10 @@ export function reconcileResourceCanvasLayout(
preserved.map((position) => position.resourceId),
);
const newResourcesBySection = new Map(
sectionOrder.map((section) => [section, [] as ResourceCanvasItem[]]),
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
section,
[] as ResourceCanvasItem[],
]),
);
for (const resource of resources) {
if (!preservedIds.has(resource.id)) {
@@ -1210,7 +1161,7 @@ export function reconcileResourceCanvasLayout(
}
}
for (const section of sectionOrder) {
for (const section of RESOURCE_CANVAS_SECTION_ORDER) {
const sectionPositions = positionsBySection.get(section) ?? [];
const sectionResources = resources.filter(
(resource) => resource.category === section,
@@ -1255,7 +1206,7 @@ export function reconcileResourceCanvasLayout(
}
}
const ordered = sectionOrder.flatMap((section) =>
const ordered = RESOURCE_CANVAS_SECTION_ORDER.flatMap((section) =>
(positionsBySection.get(section) ?? []).sort(
(left, right) =>
left.y - right.y ||
@@ -1283,11 +1234,11 @@ export function moveResourceCanvasPosition(
const finiteY = Number.isFinite(y) ? y : 0;
const normalizedX = Math.min(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
Math.max(0, Math.round(finiteX)),
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteX)),
);
const normalizedY = Math.min(
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
Math.max(0, Math.round(finiteY)),
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteY)),
);
return {
...layout,
@@ -1308,28 +1259,115 @@ 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),
};
}
export function resourceCanvasContentBounds(
positions: readonly ProjectResourceCanvasPosition[],
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId,
) {
if (positions.length === 0) {
return {
x: 0,
y: 0,
width: RESOURCE_CANVAS_SECTION_MIN_WIDTH,
height: RESOURCE_CANVAS_SECTION_MIN_HEIGHT,
};
}
const minX = Math.min(...positions.map((position) => position.x));
const minY = Math.min(...positions.map((position) => position.y));
const maxX = Math.max(
...positions.map(
(position) =>
position.x +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.width,
),
);
const maxY = Math.max(
...positions.map(
(position) =>
position.y +
resourceCanvasCardSize(position.resourceId, cardSizeByResourceId)
.height,
),
);
return {
x: Math.floor(minX),
y: Math.floor(minY),
width: Math.max(1, Math.ceil(maxX - minX)),
height: Math.max(1, Math.ceil(maxY - minY)),
};
}
export function fitResourceCanvasViewportToContent({
bounds,
canvasSize,
padding = RESOURCE_CANVAS_FIT_PADDING,
}: {
bounds: { x: number; y: number; width: number; height: number };
canvasSize: { width: number; height: number };
padding?: number;
}): CanvasViewport {
const normalizedPadding = Math.max(0, padding);
const boundsWidth = Math.max(1, bounds.width);
const boundsHeight = Math.max(1, bounds.height);
const availableWidth = Math.max(1, canvasSize.width - normalizedPadding * 2);
const availableHeight = Math.max(
1,
canvasSize.height - normalizedPadding * 2,
);
const scale = clampResourceCanvasNumber(
Math.min(availableWidth / boundsWidth, availableHeight / boundsHeight),
MIN_SCALE,
MAX_SCALE,
);
return {
x:
normalizedPadding +
availableWidth / 2 -
(bounds.x + boundsWidth / 2) * scale,
y:
normalizedPadding +
availableHeight / 2 -
(bounds.y + boundsHeight / 2) * scale,
scale,
};
}
@@ -1,4 +1,3 @@
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectResource } from './resourceProjectionModel';
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
@@ -174,17 +173,17 @@ export function projectResourceCardPreviewKind(
export function projectResourceCardPreviewIdentity(input: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
previewVersion?: string;
resource: ProjectResource;
}) {
return JSON.stringify([
input.projectPath,
input.projectId,
input.mode,
input.resource.id,
input.resource.category,
input.resource.path,
input.resource.mediaType,
input.previewVersion ?? '',
]);
}
@@ -32,7 +32,6 @@ type LayoutScope = {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resourceSignature: string;
};
type ManualLayoutWriteIntent = {
@@ -59,9 +58,8 @@ function createScopeKey(
projectPath: string,
projectId: string,
mode: ProjectResourceCanvasLayoutMode,
resourceSignature: string,
) {
return JSON.stringify([projectPath, projectId, mode, resourceSignature]);
return JSON.stringify([projectPath, projectId, mode]);
}
export function createResourceSignature(resources: ResourceCanvasItem[]) {
@@ -239,12 +237,7 @@ export function useProjectResourceCanvasLayout({
() => [createResourceSignature(resources), topologySignature].join('\n'),
[resources, topologySignature],
);
const scopeKey = createScopeKey(
projectPath,
projectId,
mode,
resourceSignature,
);
const scopeKey = createScopeKey(projectPath, projectId, mode);
const fallback = useMemo(() => {
const empty = createEmptyResourceCanvasLayout(projectId, mode);
return initializationReady || renderFallbackWhileBlocked
@@ -277,7 +270,6 @@ export function useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resourceSignature,
});
const writeQueueRef = useRef<LayoutWriteIntent[]>([]);
const activeWriteIntentRef = useRef<LayoutWriteIntent | null>(null);
@@ -604,7 +596,6 @@ export function useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resourceSignature,
};
scopeRef.current = scope;
initializedScopeEpochRef.current = null;
@@ -697,7 +688,6 @@ export function useProjectResourceCanvasLayout({
projectPath,
rebuildOptimisticLayout,
rederiveAutomaticPositions,
resourceSignature,
scopeKey,
]);
@@ -824,7 +814,7 @@ export function useProjectResourceCanvasLayout({
allResourcesPositioned;
return {
layout: scopeMatches ? layout : fallback,
layout: ready ? layout : fallback,
notice,
saving,
ready,
@@ -199,12 +199,9 @@ export function useProjectResourceCardPreviews(input: {
resources: ProjectResource[];
canvasRef: React.RefObject<HTMLDivElement | null>;
eagerPreviewLimit?: number;
previewVersionByResourceId?: ReadonlyMap<string, string>;
}) {
const scopeKey = JSON.stringify([
input.projectPath,
input.projectId,
input.mode,
]);
const scopeKey = JSON.stringify([input.projectPath, input.projectId]);
const identityByResourceId = useMemo(
() =>
new Map(
@@ -213,12 +210,18 @@ export function useProjectResourceCardPreviews(input: {
projectResourceCardPreviewIdentity({
projectPath: input.projectPath,
projectId: input.projectId,
mode: input.mode,
previewVersion:
input.previewVersionByResourceId?.get(resource.id) ?? '',
resource,
}),
]),
),
[input.mode, input.projectId, input.projectPath, input.resources],
[
input.previewVersionByResourceId,
input.projectId,
input.projectPath,
input.resources,
],
);
const [previews, setPreviews] = useState<
Map<string, ProjectResourceCardPreviewState>
@@ -10,6 +10,7 @@ import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_CANVAS_SECTION_ORDER } from './resourceCanvasLayoutModel';
import {
clampProjectResourceSectionHeight,
clampProjectResourceSectionZoom,
@@ -29,13 +30,7 @@ const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
'dependency',
'type',
];
const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [
'code',
'document',
'version',
'art',
'audio',
];
const RESOURCE_SECTIONS = RESOURCE_CANVAS_SECTION_ORDER;
export type ProjectResourceSectionHeightState = {
height: number;
@@ -726,6 +726,86 @@ describe('ResourceDependencyOverlay', () => {
}
});
it('culls paged dependency edges from the transformed canvas viewport', async () => {
const graph = graphFixture();
const originalGetBoundingClientRect =
HTMLElement.prototype.getBoundingClientRect;
const getBoundingClientRect = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getPagedCanvasRect(this: HTMLElement) {
if (this.dataset.resourceSectionScroll === 'art') {
return {
x: 0,
y: 0,
left: 0,
top: 0,
right: 600,
bottom: 240,
width: 600,
height: 240,
toJSON: () => ({}),
} as DOMRect;
}
return originalGetBoundingClientRect.call(this);
});
const view = (canvasViewport: { x: number; y: number; scale: number }) =>
React.createElement(
'div',
{ className: 'game-resource-canvas--dependency' },
React.createElement(
'div',
{ 'data-resource-section-scroll': 'art' },
React.createElement(
'div',
{ className: 'game-resource-page-world' },
React.createElement(ResourceDependencyOverlay, {
graph,
positions: [
position('source:one', 0, 0),
position('target:one', 0, 320),
],
section: 'art',
visibleResourceIds: new Set(['source:one', 'target:one']),
canvasViewport,
}),
),
),
);
try {
const rendered = render(view({ x: 0, y: 0, scale: 1 }));
const overlay = await screen.findByTestId(
'resource-dependency-overlay-art',
);
const selector = '[data-edge-kind="asset-reference"]';
await waitFor(() => {
expect(overlay.dataset.logicalViewport).toBe('0,0,600,240');
expect(
overlay
.querySelector(selector)
?.getAttribute('data-edge-continuation'),
).toBe('outgoing');
});
rendered.rerender(view({ x: 0, y: -180, scale: 1 }));
await waitFor(() => {
expect(overlay.dataset.logicalViewport).toBe('0,180,600,240');
expect(
overlay
.querySelector(selector)
?.getAttribute('data-edge-continuation'),
).toBe('incoming');
});
rendered.rerender(view({ x: 0, y: -1_000, scale: 2 }));
await waitFor(() => {
expect(overlay.dataset.logicalViewport).toBe('0,500,300,120');
expect(overlay.querySelector(selector)).toBeNull();
});
} finally {
getBoundingClientRect.mockRestore();
}
});
it('keeps drag previews in plane-local coordinates', async () => {
const getBoundingClientRect = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
@@ -167,6 +167,11 @@ export function registerClientHomeTests() {
expect(
await screen.findByRole('button', { name: /live-hero\.png/ }),
).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: '项目版本',
}),
);
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
@@ -337,6 +342,11 @@ export function registerClientHomeTests() {
{ timeout: 5_000 },
),
).not.toBeNull();
fireEvent.click(
within(screen.getByLabelText('资源栏目大纲')).getByRole('button', {
name: '项目版本',
}),
);
expect(screen.getByRole('button', { name: /版本 1/ })).not.toBeNull();
expect(runButton.getAttribute('data-unavailable')).toBeNull();
await waitFor(() => {
File diff suppressed because it is too large Load Diff
@@ -177,6 +177,8 @@ function memoryHost(input?: {
importGate?: Deferred<unknown>;
commitGate?: Deferred<void>;
generationGate?: Deferred<void>;
candidateAcknowledgementGate?: Deferred<void>;
initialUnacknowledgedCandidateLayerIds?: string[];
generationFailure?: { code: string; message: string };
generationFailureNotStarted?: boolean;
updateFailure?: { code: string; message: string };
@@ -196,6 +198,13 @@ function memoryHost(input?: {
| ((progress: ImageCanvasGenerationProgress) => void)
| undefined;
const updates: ImageCanvasDraftCanvas[] = [];
const persistenceOperations: Array<
| { kind: 'update'; canvas: ImageCanvasDraftCanvas }
| { kind: 'acknowledge'; layerIds: string[] }
> = [];
const unacknowledgedCandidateLayerIds = new Set(
input?.initialUnacknowledgedCandidateLayerIds ?? [],
);
const commits: Array<{
commitId: string;
idempotencyKey: string;
@@ -293,18 +302,52 @@ function memoryHost(input?: {
hostRevision: String(hostRevision),
};
}
updates.push(update.canvas);
const protectedLayers = [...update.canvas.layers];
for (const layerId of unacknowledgedCandidateLayerIds) {
const candidateLayer = draft.canvas.layers.find(
(layer) => layer.layerId === layerId,
);
if (
candidateLayer &&
!protectedLayers.some((layer) => layer.layerId === layerId)
) {
protectedLayers.push(candidateLayer);
}
}
const protectedCanvas = { ...update.canvas, layers: protectedLayers };
updates.push(protectedCanvas);
persistenceOperations.push({ kind: 'update', canvas: protectedCanvas });
draft = {
...draft,
revision: draft.revision + 1,
status: update.status,
canvas: update.canvas,
canvas: protectedCanvas,
generations: update.generations,
updatedAt: draft.updatedAt + 1,
};
return { status: 'ok' as const, value: draft };
},
);
const acknowledgeCandidateLayers = vi.fn(
async (
acknowledgement: Parameters<
ImageCanvasProjectPort['acknowledgeCandidateLayers']
>[0],
) => {
await input?.candidateAcknowledgementGate?.promise;
if (!draft) throw new Error('draft missing');
persistenceOperations.push({
kind: 'acknowledge',
layerIds: [...acknowledgement.layerIds],
});
for (const layerId of acknowledgement.layerIds) {
if (draft.canvas.layers.some((layer) => layer.layerId === layerId)) {
unacknowledgedCandidateLayerIds.delete(layerId);
}
}
return { status: 'ok' as const, value: draft };
},
);
const settleGenerationFailure = vi.fn(
async (
failureInput: Parameters<
@@ -399,6 +442,7 @@ function memoryHost(input?: {
return { status: 'ok', value: draft };
},
updateDraft,
acknowledgeCandidateLayers,
discardDraft,
},
asset: {
@@ -566,6 +610,7 @@ function memoryHost(input?: {
flipX: false,
flipY: false,
};
unacknowledgedCandidateLayerIds.add(candidateLayer.layerId);
draft = {
...draft,
revision: draft.revision + 1,
@@ -738,6 +783,7 @@ function memoryHost(input?: {
selectedCandidateCommits,
generationCalls,
updates,
persistenceOperations,
revokedSubscriptions,
nativeImportCalls,
loadDraft,
@@ -745,6 +791,7 @@ function memoryHost(input?: {
recoverImages,
settleGenerationFailure,
updateDraft,
acknowledgeCandidateLayers,
confirmGenerationServiceIdentity,
discardDraft,
getDraft: () => draft,
@@ -3241,6 +3288,188 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByText('已设为最终图,精修草稿可继续编辑')).toBeTruthy();
expect(onCancel).not.toHaveBeenCalled();
});
it('候选确认形成导入屏障,确认后删除并再次保存不会被宿主保护逻辑复活', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
layerId: 'source-layer-for-candidate-ack',
resourceId: 'local-asset:source-asset',
title: '候选确认入口图',
mediaRef: { kind: 'project-asset' as const, assetId: 'source-asset' },
};
const candidateAcknowledgementGate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer],
}),
candidateAcknowledgementGate,
});
renderSurface(memory.host, refineScope, vi.fn(), vi.fn(), vi.fn(), {
name: '候选确认素材',
kind: 'illustration',
});
fireEvent.click(
await screen.findByRole('button', { name: '选择图层 候选确认入口图' }),
);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '生成后确认再删除' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
const candidateButton = await screen.findByRole('button', {
name: '选择图层 候选确认素材 候选图',
});
await waitFor(() =>
expect(memory.acknowledgeCandidateLayers).toHaveBeenCalledTimes(1),
);
const candidateLayerId =
memory.acknowledgeCandidateLayers.mock.calls[0]?.[0].layerIds[0];
expect(candidateLayerId).toMatch(/^generated-layer-/);
fireEvent.click(screen.getByRole('button', { name: '导入图片' }));
await act(async () => {
await Promise.resolve();
});
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(0);
await act(async () => {
candidateAcknowledgementGate.resolve();
await candidateAcknowledgementGate.promise;
});
await waitFor(() =>
expect(memory.nativeImportCalls).toHaveBeenCalledTimes(1),
);
const candidateSaveIndex = memory.persistenceOperations.findIndex(
(operation) =>
operation.kind === 'update' &&
operation.canvas.layers.some(
(layer) => layer.layerId === candidateLayerId,
),
);
expect(candidateSaveIndex).toBeGreaterThanOrEqual(0);
expect(
memory.persistenceOperations.findIndex(
(operation) =>
operation.kind === 'acknowledge' &&
operation.layerIds.includes(candidateLayerId!),
),
).toBeGreaterThan(candidateSaveIndex);
fireEvent.click(
await screen.findByRole('button', {
name: '选择图层 候选确认素材 候选图',
}),
);
const candidateQuickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const savesBeforeDelete = memory.updateDraft.mock.calls.length;
fireEvent.click(
within(candidateQuickEdit).getByRole('button', { name: '删除' }),
);
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
savesBeforeDelete,
),
);
expect(
memory
.getDraft()
?.canvas.layers.some((layer) => layer.layerId === candidateLayerId),
).toBe(false);
});
it('重新打开 candidate-ready 草稿时先幂等确认,后续删除不会复活', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const candidateLayer = {
...keyboardCanvas().layers[0],
layerId: 'recovered-candidate-layer',
resourceId: 'draft-media:recovered-candidate',
title: '恢复候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'recovered-candidate',
mediaType: 'image/png' as const,
sha256: 'd'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
};
const initialDraft = {
...draftFixture(refineScope, {
...emptyCanvas(),
layers: [candidateLayer],
selectedLayerIds: [candidateLayer.layerId],
primarySelectedLayerId: candidateLayer.layerId,
}),
generations: [
{
generationId: 'recovered-generation',
intentId: 'recovered-intent',
phase: 'candidate-ready' as const,
referenceResourceIds: [],
outputAssetId: null,
sourceLayerId: null,
placeholder: null,
errorCode: null,
createdAt: 1,
updatedAt: 2,
},
],
};
const memory = memoryHost({
initialDraft,
initialUnacknowledgedCandidateLayerIds: [candidateLayer.layerId],
});
renderSurface(memory.host, refineScope);
await waitFor(() =>
expect(memory.acknowledgeCandidateLayers).toHaveBeenCalledWith({
scope: refineScope,
layerIds: [candidateLayer.layerId],
}),
);
const candidateButton = await screen.findByRole('button', {
name: '选择图层 恢复候选图',
});
await waitFor(() =>
expect((candidateButton as HTMLButtonElement).disabled).toBe(false),
);
fireEvent.click(candidateButton);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
const savesBeforeDelete = memory.updateDraft.mock.calls.length;
fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' }));
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
savesBeforeDelete,
),
);
expect(
memory
.getDraft()
?.canvas.layers.some(
(layer) => layer.layerId === candidateLayer.layerId,
),
).toBe(false);
});
it('精修图片直接修改时保留源资源引用、来源图层和生成占位', async () => {
const refineScope: ImageCanvasHostScope = {
projectId: 'project-refine',
@@ -3725,6 +3954,11 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
manifest,
};
}
if (
command === 'acknowledge_local_project_asset_canvas_candidate_layers'
) {
return { draft: draftFixture(scope) };
}
throw new Error(`unexpected command: ${command}`);
},
);
@@ -3765,6 +3999,23 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(commitRequests).toHaveLength(2);
expect(commitRequests[0]?.[1]).toEqual(commitRequests[1]?.[1]);
const acknowledgement = await adapter.project.acknowledgeCandidateLayers({
scope,
layerIds: ['candidate-layer'],
});
expect(acknowledgement.status).toBe('ok');
expect(invokeSpy).toHaveBeenCalledWith(
'acknowledge_local_project_asset_canvas_candidate_layers',
expect.objectContaining({
input: expect.objectContaining({
projectPath: '/fixture/project',
expectedProjectId: scope.projectId,
draftId: scope.draftId,
layerIds: ['candidate-layer'],
}),
}),
);
const mismatch = await adapter.project.loadDraft({
...scope,
projectId: 'replacement-project',
@@ -3775,7 +4026,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
code: 'project-identity-conflict',
}),
);
expect(invokeSpy).toHaveBeenCalledTimes(3);
expect(invokeSpy).toHaveBeenCalledTimes(4);
const invalidRevision = await adapter.completion.commitImage({
...commitInput,
@@ -3786,7 +4037,7 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(invalidRevision).toEqual(
expect.objectContaining({ status: 'failed' }),
);
expect(invokeSpy).toHaveBeenCalledTimes(3);
expect(invokeSpy).toHaveBeenCalledTimes(4);
});
it('Tauri adapter 不向生成与恢复命令透传登录 Token 或 External API Key', async () => {
@@ -864,7 +864,7 @@ describe('project resource live canvas integration', () => {
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', {
name: '打开资源详情:文档 rules.md',
name: '打开资源详情:设计文档 rules.md',
}),
).not.toBeNull();
});
@@ -2,14 +2,16 @@ import { describe, expect, it } from 'vitest';
import {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
isSafeProjectResourceCanvasCoordinate,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import {
constrainResourceCanvasViewport,
createEmptyResourceCanvasLayout,
createResourceCanvasCardSizeByResourceId,
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
fitResourceCanvasViewportToContent,
moveResourceCanvasPosition,
normalizeInfiniteResourceCanvasViewport,
reconcileResourceCanvasLayout,
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
@@ -17,6 +19,7 @@ import {
RESOURCE_CANVAS_DEPENDENCY_COLUMN_GAP,
RESOURCE_CANVAS_DEPENDENCY_ROW_GAP,
type ResourceCanvasCardSize,
resourceCanvasContentBounds,
resourceCanvasImageCardSize,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
@@ -57,44 +60,42 @@ function positionById(
}
describe('resource canvas layout model', () => {
it('clamps dependency-canvas panning to the resource union and safety margin', () => {
const common = {
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
canvasSize: { width: 800, height: 600 },
};
it('allows infinite background panning while keeping zoom within shared limits', () => {
expect(
constrainResourceCanvasViewport({
...common,
viewport: { x: 2_000, y: 2_000, scale: 1 },
normalizeInfiniteResourceCanvasViewport({
x: 2_000,
y: 2_000,
scale: 1,
}),
).toEqual({ x: 96, y: 96, scale: 1 });
).toEqual({ x: 2_000, y: 2_000, scale: 1 });
expect(
constrainResourceCanvasViewport({
...common,
viewport: { x: -2_000, y: -2_000, scale: 1 },
normalizeInfiniteResourceCanvasViewport({
x: -2_000,
y: -2_000,
scale: 0.001,
}),
).toEqual({ x: -296, y: -296, scale: 1 });
).toEqual({ x: -2_000, y: -2_000, scale: 0.025 });
});
it('stops zooming out once all resources and the hidden margin fit', () => {
expect(
constrainResourceCanvasViewport({
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
canvasSize: { width: 800, height: 600 },
viewport: { x: -800, y: 900, scale: 0.001 },
}),
).toEqual({ x: 145, y: 96, scale: 0.51 });
it('does not pull the viewport back when resource extents change', () => {
const before = normalizeInfiniteResourceCanvasViewport({
x: 12_000,
y: -9_000,
scale: 0.75,
});
const after = normalizeInfiniteResourceCanvasViewport(before);
expect(after).toEqual(before);
});
it('centers a resource boundary smaller than the viewport instead of allowing blank panning', () => {
it('repairs invalid viewport coordinates without adding a canvas boundary', () => {
expect(
constrainResourceCanvasViewport({
bounds: { x: 20, y: 30, width: 200, height: 100 },
canvasSize: { width: 800, height: 600 },
viewport: { x: -5_000, y: 5_000, scale: 1 },
normalizeInfiniteResourceCanvasViewport({
x: Number.NaN,
y: Number.POSITIVE_INFINITY,
scale: Number.NaN,
}),
).toEqual({ x: 280, y: 220, scale: 1 });
).toEqual({ x: 0, y: 0, scale: 1 });
});
it('creates non-overlapping defaults and keeps the two modes independent', () => {
@@ -179,6 +180,32 @@ describe('resource canvas layout model', () => {
).toBe(false);
});
it('preserves negative manual positions within the symmetric canvas range', () => {
const base = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-negative', 'type'),
[resource('a', 'document')],
).layout;
const moved = moveResourceCanvasPosition(
base,
'a',
'document',
-123.4,
-45.6,
);
expect(moved.positions[0]).toMatchObject({ x: -123, y: -46 });
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
),
).toBe(true);
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
),
).toBe(false);
});
it('drops a persisted position whose section no longer matches the resource', () => {
const layout = createEmptyResourceCanvasLayout('project-1', 'dependency');
layout.positions = [
@@ -555,6 +582,16 @@ describe('resource canvas layout model', () => {
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE + 1,
),
).toBe(false);
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
),
).toBe(true);
expect(
isSafeProjectResourceCanvasCoordinate(
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
),
).toBe(false);
expect(deepPositions.map((position) => position.x)).toEqual([
lastLegalColumn,
lastLegalColumn,
@@ -714,11 +751,86 @@ 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('fits the exact resource card bounds to the reset viewport', () => {
const positions: ProjectResourceCanvasPosition[] = [
{
resourceId: 'negative-card',
section: 'art',
x: -240,
y: -160,
manuallyPlaced: true,
},
{
resourceId: 'positive-card',
section: 'art',
x: 480,
y: 320,
manuallyPlaced: true,
},
];
const bounds = resourceCanvasContentBounds(
positions,
new Map([
['negative-card', { width: 180, height: 128 }],
['positive-card', { width: 220, height: 180 }],
]),
);
expect(bounds).toEqual({
x: -240,
y: -160,
width: 940,
height: 660,
});
const viewport = fitResourceCanvasViewportToContent({
bounds,
canvasSize: { width: 800, height: 600 },
});
expect(viewport.scale).toBeCloseTo(768 / 940, 8);
expect(bounds.width * viewport.scale).toBeCloseTo(768, 8);
expect(viewport.x + bounds.x * viewport.scale).toBeCloseTo(16, 8);
});
it('accepts card sizes directly on resource items for hook integration', () => {
const wide = {
...resource('wide', 'art', 0),
@@ -480,7 +480,7 @@ describe('useProjectResourceCanvasLayout', () => {
expect(result.current.saving).toBe(false);
});
it('starts a new signed scope and ignores the old layout read when resources change', async () => {
it('keeps the initial layout read and coordinates the latest resources in the same scope', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const resolveReads: Array<(layout: ProjectResourceCanvasLayout) => void> =
@@ -530,16 +530,8 @@ describe('useProjectResourceCanvasLayout', () => {
await waitFor(() => expect(resolveReads).toHaveLength(1));
rerender({ resources: [resourceA, resourceB] });
await waitFor(() => expect(resolveReads).toHaveLength(2));
await act(async () => {
resolveReads[0]?.(
persistedLayout('dependency', 99, [position('resource-a', 900, 900)]),
);
await Promise.resolve();
});
expect(updates).toHaveLength(0);
await act(async () => {
resolveReads[1]?.(
persistedLayout('dependency', 1, [position('resource-a', 10, 20)]),
);
await Promise.resolve();
@@ -560,10 +552,10 @@ describe('useProjectResourceCanvasLayout', () => {
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_resource_canvas_layout',
),
).toHaveLength(2);
).toHaveLength(1);
});
it('does not let an old in-flight layout CAS block or overwrite a new signed scope', async () => {
it('keeps an in-flight manual CAS ahead of resource coordination in the same scope', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const updates: Array<{
@@ -629,31 +621,32 @@ describe('useProjectResourceCanvasLayout', () => {
),
).toBe(true),
);
expect(updates).toHaveLength(1);
await act(async () => {
resolveFirstUpdate?.({
status: 'updated',
layout: persistedLayout(
'dependency',
2,
structuredClone(updates[0]!.positions),
),
});
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([
1, 1,
1, 2,
]);
expect(
updates[1]?.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true);
await act(async () => {
resolveFirstUpdate?.({
status: 'conflict',
layout: persistedLayout(
'dependency',
2,
structuredClone(updates[1]!.positions),
),
});
await Promise.resolve();
});
expect(result.current.layout.positions).toEqual(
expect.arrayContaining([
expect.objectContaining({ resourceId: 'resource-a', x: 10, y: 20 }),
expect.objectContaining({ resourceId: 'resource-a', x: 100, y: 30 }),
expect.objectContaining({ resourceId: 'resource-b' }),
]),
);
@@ -617,13 +617,13 @@ describe('useProjectResourceCardPreviews', () => {
await waitFor(() => expect(previewReadCalls(invoke)).toHaveLength(7));
});
it('rotates the native scope for a mode-only change and cancels the final scope on unmount', () => {
it('preserves the preview identity, dimensions, and native scope for a mode-only change', async () => {
const art = resource('mode-switch-art');
const invoke = vi.fn((command: string) => {
const invoke = vi.fn(async (command: string) => {
if (command === 'cancel_local_project_resource_preview_scope') {
return Promise.resolve();
return undefined;
}
return new Promise(() => undefined);
return preview(art.path);
});
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
@@ -635,35 +635,35 @@ describe('useProjectResourceCardPreviews', () => {
mode,
resources: [art],
canvasRef,
eagerPreviewLimit: 1,
}),
{ initialProps: 'dependency' as const },
);
act(() =>
result.current.requestPreview(
art,
result.current.identityByResourceId.get(art.id)!,
'visible',
),
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'),
);
const firstScopeId = previewReadCalls(invoke)[0]?.[1]?.scopeId;
expect(firstScopeId).toEqual(expect.any(String));
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
rerender('type');
expect(invoke).toHaveBeenCalledWith(
'cancel_local_project_resource_preview_scope',
{ scopeId: firstScopeId },
);
act(() =>
result.current.requestPreview(
art,
result.current.identityByResourceId.get(art.id)!,
'visible',
expect(result.current.identityByResourceId.get(art.id)).toBe(firstIdentity);
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded');
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
expect(previewReadCalls(invoke)).toHaveLength(1);
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'cancel_local_project_resource_preview_scope',
),
);
const secondScopeId = previewReadCalls(invoke)[1]?.[1]?.scopeId;
expect(secondScopeId).toEqual(expect.any(String));
expect(secondScopeId).not.toBe(firstScopeId);
).toHaveLength(0);
unmount();
const cancelledScopeIds = invoke.mock.calls
@@ -672,7 +672,57 @@ describe('useProjectResourceCardPreviews', () => {
command === 'cancel_local_project_resource_preview_scope',
)
.map((call) => call[1]?.scopeId);
expect(cancelledScopeIds).toEqual([firstScopeId, secondScopeId]);
expect(cancelledScopeIds).toEqual([firstScopeId]);
});
it('invalidates an in-place asset preview when its committed version changes', async () => {
const art = resource('refined-art');
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) =>
preview(String(args?.relativePath ?? art.path)),
);
window.__TAURI__ = { core: { invoke } };
const canvasRef = { current: document.createElement('div') };
const { result, rerender } = renderHook(
(previewVersion: string) =>
useProjectResourceCardPreviews({
projectPath: '/tmp/preview-refine-version',
projectId: 'preview-refine-version',
mode: 'dependency',
resources: [art],
canvasRef,
eagerPreviewLimit: 1,
previewVersionByResourceId: new Map([[art.id, previewVersion]]),
}),
{ initialProps: 'commit-1' },
);
const firstIdentity = result.current.identityByResourceId.get(art.id)!;
await waitFor(() =>
expect(result.current.previews.get(firstIdentity)?.status).toBe('loaded'),
);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
rerender('commit-2');
const secondIdentity = result.current.identityByResourceId.get(art.id)!;
expect(secondIdentity).not.toBe(firstIdentity);
await waitFor(() =>
expect(URL.revokeObjectURL).toHaveBeenCalledWith(
'blob:resource-preview-0',
),
);
await waitFor(() =>
expect(result.current.previews.get(secondIdentity)?.status).toBe(
'loaded',
),
);
expect(previewReadCalls(invoke)).toHaveLength(2);
expect(URL.createObjectURL).toHaveBeenCalledTimes(2);
expect(result.current.previews.has(firstIdentity)).toBe(false);
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
pixelWidth: 640,
pixelHeight: 360,
});
});
it('rejects an A-B-A late result even when scope and identity match again', async () => {

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