完善 Game Agent 资源画布与图片精修生成事务 (#181)
## 背景 本合并请求整合 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:
@@ -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,
|
||||
¤t_manifest,
|
||||
¤t_manifest_sha,
|
||||
¤t_revision,
|
||||
¤t_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,
|
||||
],
|
||||
);
|
||||
|
||||
+24
-1
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
+16
-8
@@ -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
+142
-104
@@ -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 ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+3
-13
@@ -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,
|
||||
|
||||
+10
-7
@@ -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>
|
||||
|
||||
+2
-7
@@ -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
Reference in New Issue
Block a user