修复完美像素结果持久化与未知状态收口
Project CI / Frontend tests (pull_request) Failing after 22s
Project CI / Repository checks (pull_request) Failing after 48s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Native shell tests (pull_request) Successful in 11m39s

为完美像素操作引入稳定标识、请求指纹和原子持久化 procedure,支持精确重放并拒绝冲突。

在首次对象存储写入后完整标记 resultPersistenceStarted,统一未知结果的 GET 对账语义。

前端持久化 perfectPixelOperation,提交前严格刷写布局,并支持 pending-confirmation、刷新恢复与原样重试。

隔离跨用户与跨项目回包,修正占位清理、动画状态和异常对账时间窗。

同步生成 SpacetimeDB 绑定,补充前后端回归测试,并更新技术文档与共享决策记录。
This commit is contained in:
2026-08-04 02:29:53 +00:00
parent 89914b2255
commit 7f13af6135
36 changed files with 6331 additions and 916 deletions
@@ -6183,3 +6183,15 @@
- 后果分级要说清:缺陷一是永久性的 UI 卡死,缺陷二只是存储层孤儿对象(confirm 注册的是 asset object,不是素材库条目,用户基本不可见)。两者同为 P2 但不同量级。
- 验证:新增用例让项目快照读取永不 settle,断言 120 秒后完美像素状态回到「空闲」——即 `finally` 确实执行。去掉对账 deadline 后该用例变红。`vitest src/components/image-editor src/services/image-editor` 974 通过,typecheck、eslint、check:encoding 通过。
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-08-03 完美像素第一批:稳定 operation 与单事务数据库提交
- 被纠正的旧边界:完美像素原先在 OSS PUT / HEAD 后顺序执行 asset object confirm、project resource、账号素材与 canvas completion 四次独立数据库调用。任一后段失败或本地 timeout/drop 都可能留下可见的部分事实;重试又使用随机 task / object / resource / asset ID,无法把同一次逻辑操作识别为重放。历史 decision 条目保留为当时证据,本条取代其“继续沿用非事务顺序”的现役结论。
- operation identity:规范化 `canvasCompletion.dialogId` 即 operationId,由 owner + project 共同限定作用域。`taskId = pixel-art-snap-{operationId}`,让响应丢失时的客户端无需服务端回包即可计算;asset object、project resource 与账号素材 ID 用 `SHA256(editor-pixel-art-result-v1 + owner + project + operation + record kind)` 的前 16 bytes 稳定派生。请求 fingerprint 为 64 位 SHA-256,覆盖来源 object key、来源和输出字节摘要、来源资源、素材类型、规范目录 / 标签、canonical generationInputs、canvas completion 与算法版本;最终 OSS object key 必须携带该 fingerprint。
- 原子边界:OSS PUT / HEAD 仍在事务外。验证上传后,`persist_editor_pixel_art_result_and_return` 受 editor generation runtime service identity 保护,在一次 `try_with_tx` 中校验并写入 asset object、project resource、editor asset,并在同一事务内读取最新 canvas、按当前 revision 完成 dialog。handler 禁止在 procedure 前调用旧 `confirm_asset_object`、resource、asset 或 completion helper。该保证只覆盖这四类结果事实;前置 owner-scoped 项目 / 素材读取仍可能沿用既有 `ensure_default_canvas / ensure_default_asset_folder` 懒建基础记录,不宣称整个 preflight 对数据库零写入。
- 重放与冲突:三条稳定记录完整且业务内容相同才返回 `AlreadyApplied`,重放不得再次执行 layout CAS 或推进 revision;任一稳定 ID 指向不同内容、同 object location 被其他 ID 占用、同 operation 输入漂移或三条记录只有部分存在都失败关闭并映射 HTTP `409`。时间字段不参与 exact replay 内容比较。权威 dialog 已删除时三条记录同事务提交、canvas / revision 不变并返回 `DialogMissing`
- 未知结果语义:本地 procedure future 的 timeout/drop 不能撤销远端事务,所以首个 PUT 后继续设置 `resultPersistenceStarted=true`;该标记现在表示“OSS 或整笔数据库事务的结果未知”,不再表示数据库可能部分提交。事务失败后允许留下无引用 OSS object,本批不做破坏性删除或历史孤儿清理。
- 明确延期:本批只交付后端原子性与可重放身份。前端仍需后续批次持久化 operation 请求快照、让素材刷新退出 verdict、轮询项目事实、引入 `pending-confirmation`、刷新后只恢复 GET,并让人工重试复用原 operation;在此之前不能宣称 unknown-result 已端到端闭环。
- 2026-08-03 第二批边界:generation dialog 持久化版本化 `perfectPixelOperation`,绑定规范化 dialog/operation、固定 `pixel-art-snap-{operationId}` task、稳定来源解析后的完整 POST 请求以及 `submittedAt / reconcileUntil` 整链绝对窗口。只有布局 PATCH 已确认包含该快照才允许首次 POST;素材刷新退出 verdict。响应未知后按稳定 task resource 与 dialog/layer 的原子事务形状有界轮询项目 GET,未终态或读取到期统一保持 `pending-confirmation`,不标普通失败、不自动重放。显式人工重试必须 byte-for-byte 复用持久请求和同一 identity,当前 UI、来源、目录、类型或标题变化不得改变请求;无效快照失败关闭。首次提交或重试在途时 owner、project 或组件生命周期改变后,旧响应的素材、项目、提示和对账副作用全部忽略。
- 2026-08-03 第三批边界:hydrate 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。v1 快照跨度不得超过固定 240 秒,读取时面对未来时钟偏差也至多从当前再观察一个窗口。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation 快照的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,96 @@
use super::*;
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtCanvasPlaceholderRecordInput {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub original_width: f64,
pub original_height: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtCanvasCompletionRecordInput {
pub dialog_id: String,
pub title: String,
pub placeholder: EditorPixelArtCanvasPlaceholderRecordInput,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtResultPersistRecordInput {
pub owner_user_id: String,
pub project_id: String,
pub operation_id: String,
pub operation_fingerprint: String,
pub asset_object: module_assets::AssetObjectUpsertInput,
pub project_resource: EditorProjectResourceCreateRecordInput,
pub asset: EditorAssetCreateRecordInput,
pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput,
pub completed_at_micros: i64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditorPixelArtResultPersistRecordStatus {
Applied,
DialogMissing,
AlreadyApplied,
}
#[derive(Clone, Debug, PartialEq)]
pub struct EditorPixelArtResultPersistRecord {
pub status: EditorPixelArtResultPersistRecordStatus,
pub asset_object: module_assets::AssetObjectUpsertSnapshot,
pub project_resource: EditorProjectResourceRecord,
pub asset: EditorAssetRecord,
pub project: Option<EditorProjectRecord>,
}
impl From<EditorPixelArtCanvasPlaceholderRecordInput>
for crate::module_bindings::EditorPixelArtCanvasPlaceholderInput
{
fn from(input: EditorPixelArtCanvasPlaceholderRecordInput) -> Self {
Self {
x: input.x,
y: input.y,
width: input.width,
height: input.height,
original_width: input.original_width,
original_height: input.original_height,
}
}
}
impl From<EditorPixelArtCanvasCompletionRecordInput>
for crate::module_bindings::EditorPixelArtCanvasCompletionInput
{
fn from(input: EditorPixelArtCanvasCompletionRecordInput) -> Self {
Self {
dialog_id: input.dialog_id,
title: input.title,
placeholder: input.placeholder.into(),
}
}
}
impl From<EditorPixelArtResultPersistRecordInput>
for crate::module_bindings::EditorPixelArtResultPersistInput
{
fn from(input: EditorPixelArtResultPersistRecordInput) -> Self {
Self {
owner_user_id: input.owner_user_id,
project_id: input.project_id,
operation_id: input.operation_id,
operation_fingerprint: input.operation_fingerprint,
asset_object: input.asset_object.into(),
project_resource: input.project_resource.into(),
asset: input.asset.into(),
canvas_completion: input.canvas_completion.into(),
completed_at_micros: input.completed_at_micros,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorSpritesheetSlicePersistItemRecordInput {
pub asset_object: module_assets::AssetObjectUpsertInput,
@@ -57,6 +148,31 @@ impl From<EditorSpritesheetSliceBatchPersistRecordInput>
}
impl SpacetimeClient {
pub async fn persist_editor_pixel_art_result(
&self,
input: EditorPixelArtResultPersistRecordInput,
) -> Result<EditorPixelArtResultPersistRecord, SpacetimeClientError> {
let procedure_input = input.into();
self.call_after_connect(
"persist_editor_pixel_art_result_and_return",
move |connection, sender| {
connection
.procedures()
.persist_editor_pixel_art_result_and_return_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_editor_pixel_art_result_persist_result);
send_once(&sender, mapped);
},
);
},
)
.await
}
pub async fn persist_editor_spritesheet_slice_batch(
&self,
input: EditorSpritesheetSliceBatchPersistRecordInput,
@@ -981,6 +1097,58 @@ impl SpacetimeClient {
}
}
fn map_editor_pixel_art_result_persist_result(
result: crate::module_bindings::EditorPixelArtResultPersistResult,
) -> Result<EditorPixelArtResultPersistRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let status = match result
.status
.ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少状态"))?
{
crate::module_bindings::EditorPixelArtResultPersistStatus::Applied => {
EditorPixelArtResultPersistRecordStatus::Applied
}
crate::module_bindings::EditorPixelArtResultPersistStatus::DialogMissing => {
EditorPixelArtResultPersistRecordStatus::DialogMissing
}
crate::module_bindings::EditorPixelArtResultPersistStatus::AlreadyApplied => {
EditorPixelArtResultPersistRecordStatus::AlreadyApplied
}
};
let asset_object = result.asset_object.ok_or_else(|| {
SpacetimeClientError::validation_failed("完美像素持久化结果缺少 asset object")
})?;
let project_resource = result
.project_resource
.ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少项目资源"))?;
let asset = result
.asset
.ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少账号素材"))?;
let project = result
.project
.map(|project| {
map_editor_project_optional_procedure_result(
crate::module_bindings::EditorProjectProcedureResult {
ok: true,
project: Some(project),
error_message: None,
},
)
})
.transpose()?
.flatten();
Ok(EditorPixelArtResultPersistRecord {
status,
asset_object: map_editor_spritesheet_asset_object_snapshot(asset_object),
project_resource: map_editor_spritesheet_project_resource_snapshot(project_resource)?,
asset: map_editor_spritesheet_asset_snapshot(asset)?,
project,
})
}
fn map_editor_spritesheet_slice_batch_persist_result(
result: crate::module_bindings::EditorSpritesheetSliceBatchPersistResult,
) -> Result<EditorSpritesheetSliceBatchPersistRecord, SpacetimeClientError> {
@@ -1113,3 +1281,174 @@ fn parse_editor_spritesheet_generation_inputs(
})
.transpose()
}
#[cfg(test)]
mod pixel_art_persist_mapper_tests {
use super::*;
fn empty_pixel_art_persist_result(
ok: bool,
) -> crate::module_bindings::EditorPixelArtResultPersistResult {
crate::module_bindings::EditorPixelArtResultPersistResult {
ok,
status: None,
asset_object: None,
project_resource: None,
asset: None,
project: None,
error_message: None,
}
}
fn pixel_art_asset_object_snapshot() -> crate::module_bindings::AssetObjectUpsertSnapshot {
crate::module_bindings::AssetObjectUpsertSnapshot {
asset_object_id: "assetobj_pixel".to_string(),
bucket: "private".to_string(),
object_key: "editor/pixel.png".to_string(),
access_policy: crate::module_bindings::AssetObjectAccessPolicy::Private,
content_type: Some("image/png".to_string()),
content_length: 16,
content_hash: Some("output-sha256".to_string()),
version: 1,
source_job_id: Some("pixel-art-snap-dialog-1".to_string()),
owner_user_id: Some("user-1".to_string()),
profile_id: None,
entity_id: Some("project-1".to_string()),
asset_kind: "image".to_string(),
created_at_micros: 10,
updated_at_micros: 11,
}
}
fn pixel_art_project_resource_snapshot() -> crate::module_bindings::EditorProjectResourceSnapshot
{
crate::module_bindings::EditorProjectResourceSnapshot {
resource_id: "editor-resource-pixel".to_string(),
project_id: "project-1".to_string(),
owner_user_id: "user-1".to_string(),
asset_object_id: Some("assetobj_pixel".to_string()),
image_src: "/api/assets/assetobj_pixel/content".to_string(),
object_key: Some("editor/pixel.png".to_string()),
width: 32,
height: 32,
source_type: "perfect-pixel".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: Some("pixel-art-snap-dialog-1".to_string()),
source_resource_id: Some("source-resource".to_string()),
asset_kind: Some("image".to_string()),
generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()),
public_showcase_enabled: false,
created_at_micros: 10,
updated_at_micros: 11,
}
}
fn pixel_art_asset_snapshot() -> crate::module_bindings::EditorAssetSnapshot {
crate::module_bindings::EditorAssetSnapshot {
asset_id: "editor-asset-pixel".to_string(),
folder_id: "folder-1".to_string(),
label: "Pixel result".to_string(),
asset_object_id: Some("assetobj_pixel".to_string()),
image_src: "/api/assets/assetobj_pixel/content".to_string(),
object_key: Some("editor/pixel.png".to_string()),
width: 32,
height: 32,
source_type: "perfect-pixel".to_string(),
prompt: None,
actual_prompt: None,
model: None,
provider: None,
task_id: Some("pixel-art-snap-dialog-1".to_string()),
asset_kind: Some("image".to_string()),
generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()),
source_resource_id: Some("source-resource".to_string()),
public_showcase_enabled: Some(false),
created_at_micros: 10,
updated_at_micros: 11,
thumbnail_src: None,
generation_cost_mud_points: 0,
showcase_id: None,
showcase_review_status: None,
showcase_display_enabled: None,
showcase_like_count: None,
group_task_id: None,
}
}
fn successful_pixel_art_persist_result(
status: crate::module_bindings::EditorPixelArtResultPersistStatus,
) -> crate::module_bindings::EditorPixelArtResultPersistResult {
crate::module_bindings::EditorPixelArtResultPersistResult {
ok: true,
status: Some(status),
asset_object: Some(pixel_art_asset_object_snapshot()),
project_resource: Some(pixel_art_project_resource_snapshot()),
asset: Some(pixel_art_asset_snapshot()),
project: None,
error_message: None,
}
}
#[test]
fn editor_pixel_art_persist_mapper_rejects_procedure_failure_before_snapshots() {
let mut result = empty_pixel_art_persist_result(false);
result.error_message = Some("事务提交失败".to_string());
let error = map_editor_pixel_art_result_persist_result(result)
.expect_err("ok=false 必须保留 procedure 失败");
assert!(matches!(
error,
SpacetimeClientError::Procedure(message) if message == "事务提交失败"
));
}
#[test]
fn editor_pixel_art_persist_mapper_rejects_missing_required_snapshot() {
let mut result = empty_pixel_art_persist_result(true);
result.status = Some(crate::module_bindings::EditorPixelArtResultPersistStatus::Applied);
let error = map_editor_pixel_art_result_persist_result(result)
.expect_err("成功结果缺少 asset object 时必须拒绝");
assert!(matches!(
error,
SpacetimeClientError::Runtime(message)
if message == "完美像素持久化结果缺少 asset object"
));
}
#[test]
fn editor_pixel_art_persist_mapper_preserves_status_and_accepts_missing_project() {
let cases = [
(
crate::module_bindings::EditorPixelArtResultPersistStatus::Applied,
EditorPixelArtResultPersistRecordStatus::Applied,
),
(
crate::module_bindings::EditorPixelArtResultPersistStatus::DialogMissing,
EditorPixelArtResultPersistRecordStatus::DialogMissing,
),
(
crate::module_bindings::EditorPixelArtResultPersistStatus::AlreadyApplied,
EditorPixelArtResultPersistRecordStatus::AlreadyApplied,
),
];
for (binding_status, expected_status) in cases {
let mapped = map_editor_pixel_art_result_persist_result(
successful_pixel_art_persist_result(binding_status),
)
.expect("project=None 仍应映射成功");
assert_eq!(mapped.status, expected_status);
assert_eq!(mapped.asset_object.asset_object_id, "assetobj_pixel");
assert_eq!(mapped.project_resource.resource_id, "editor-resource-pixel");
assert_eq!(mapped.asset.asset_id, "editor-asset-pixel");
assert!(mapped.project.is_none());
}
}
}
@@ -297,6 +297,11 @@ pub mod editor_generation_pricing_tier_type;
pub mod editor_generation_runtime_identity_rotate_input_type;
pub mod editor_generation_runtime_identity_rotation_table;
pub mod editor_generation_runtime_identity_rotation_type;
pub mod editor_pixel_art_canvas_completion_input_type;
pub mod editor_pixel_art_canvas_placeholder_input_type;
pub mod editor_pixel_art_result_persist_input_type;
pub mod editor_pixel_art_result_persist_result_type;
pub mod editor_pixel_art_result_persist_status_type;
pub mod editor_project_create_input_type;
pub mod editor_project_delete_input_type;
pub mod editor_project_delete_procedure_result_type;
@@ -475,6 +480,7 @@ pub mod npc_relation_state_type;
pub mod npc_stance_profile_type;
pub mod npc_state_table;
pub mod npc_state_type;
pub mod persist_editor_pixel_art_result_and_return_procedure;
pub mod persist_editor_spritesheet_slice_batch_and_return_procedure;
pub mod player_progression_grant_source_type;
pub mod player_progression_table;
@@ -1120,6 +1126,11 @@ pub use editor_generation_pricing_tier_type::EditorGenerationPricingTier;
pub use editor_generation_runtime_identity_rotate_input_type::EditorGenerationRuntimeIdentityRotateInput;
pub use editor_generation_runtime_identity_rotation_table::*;
pub use editor_generation_runtime_identity_rotation_type::EditorGenerationRuntimeIdentityRotation;
pub use editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput;
pub use editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput;
pub use editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput;
pub use editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult;
pub use editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus;
pub use editor_project_create_input_type::EditorProjectCreateInput;
pub use editor_project_delete_input_type::EditorProjectDeleteInput;
pub use editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult;
@@ -1298,6 +1309,7 @@ pub use npc_relation_state_type::NpcRelationState;
pub use npc_stance_profile_type::NpcStanceProfile;
pub use npc_state_table::*;
pub use npc_state_type::NpcState;
pub use persist_editor_pixel_art_result_and_return_procedure::persist_editor_pixel_art_result_and_return;
pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return;
pub use player_progression_grant_source_type::PlayerProgressionGrantSource;
pub use player_progression_table::*;
@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorPixelArtCanvasCompletionInput {
pub dialog_id: String,
pub title: String,
pub placeholder: EditorPixelArtCanvasPlaceholderInput,
}
impl __sdk::InModule for EditorPixelArtCanvasCompletionInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorPixelArtCanvasPlaceholderInput {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
pub original_width: f64,
pub original_height: f64,
}
impl __sdk::InModule for EditorPixelArtCanvasPlaceholderInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,28 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_upsert_input_type::AssetObjectUpsertInput;
use super::editor_asset_create_input_type::EditorAssetCreateInput;
use super::editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput;
use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorPixelArtResultPersistInput {
pub owner_user_id: String,
pub project_id: String,
pub operation_id: String,
pub operation_fingerprint: String,
pub asset_object: AssetObjectUpsertInput,
pub project_resource: EditorProjectResourceCreateInput,
pub asset: EditorAssetCreateInput,
pub canvas_completion: EditorPixelArtCanvasCompletionInput,
pub completed_at_micros: i64,
}
impl __sdk::InModule for EditorPixelArtResultPersistInput {
type Module = super::RemoteModule;
}
@@ -0,0 +1,27 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot;
use super::editor_asset_snapshot_type::EditorAssetSnapshot;
use super::editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus;
use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot;
use super::editor_project_snapshot_type::EditorProjectSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct EditorPixelArtResultPersistResult {
pub ok: bool,
pub status: Option<EditorPixelArtResultPersistStatus>,
pub asset_object: Option<AssetObjectUpsertSnapshot>,
pub project_resource: Option<EditorProjectResourceSnapshot>,
pub asset: Option<EditorAssetSnapshot>,
pub project: Option<EditorProjectSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for EditorPixelArtResultPersistResult {
type Module = super::RemoteModule;
}
@@ -0,0 +1,20 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum EditorPixelArtResultPersistStatus {
Applied,
DialogMissing,
AlreadyApplied,
}
impl __sdk::InModule for EditorPixelArtResultPersistStatus {
type Module = super::RemoteModule;
}
@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput;
use super::editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct PersistEditorPixelArtResultAndReturnArgs {
pub input: EditorPixelArtResultPersistInput,
}
impl __sdk::InModule for PersistEditorPixelArtResultAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `persist_editor_pixel_art_result_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait persist_editor_pixel_art_result_and_return {
fn persist_editor_pixel_art_result_and_return(&self, input: EditorPixelArtResultPersistInput) {
self.persist_editor_pixel_art_result_and_return_then(input, |_, _| {});
}
fn persist_editor_pixel_art_result_and_return_then(
&self,
input: EditorPixelArtResultPersistInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorPixelArtResultPersistResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl persist_editor_pixel_art_result_and_return for super::RemoteProcedures {
fn persist_editor_pixel_art_result_and_return_then(
&self,
input: EditorPixelArtResultPersistInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<EditorPixelArtResultPersistResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, EditorPixelArtResultPersistResult>(
"persist_editor_pixel_art_result_and_return",
PersistEditorPixelArtResultAndReturnArgs { input },
__callback,
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,7 @@ import type {
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorImageGenerationStyle,
EditorPixelArtSnapInput,
EditorVideoAspectRatio,
EditorVideoModel,
EditorVideoResolution,
@@ -182,6 +183,16 @@ export type PublicationMaterialsWorkflowId =
| 'publication-detail-gallery'
| 'publication-promo-poster';
export type PerfectPixelOperationSnapshot = {
version: 1;
kind: 'perfect-pixel';
operationId: string;
taskId: string;
request: EditorPixelArtSnapInput;
submittedAt: number;
reconcileUntil: number;
};
export type GenerateDialogState = {
id?: string;
mode:
@@ -199,7 +210,7 @@ export type GenerateDialogState = {
| 'audio-background-music';
prompt: string;
assetLabel?: string;
status: 'idle' | 'generating' | 'failed';
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
composerOpen?: boolean;
sourceLayerId?: string;
generatedLayerId?: string;
@@ -242,6 +253,8 @@ export type GenerateDialogState = {
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
// 必须原样恢复。
requiresLiveSession?: boolean;
perfectPixelOperation?: PerfectPixelOperationSnapshot;
perfectPixelOperationInvalid?: boolean;
generationStartedAt?: number;
generationFinishedAt?: number;
placeholder?: {
@@ -749,6 +749,7 @@ export function ImageCanvasEditorView({
inactiveGenerateDialogsRef,
activeCanvasGenerationDialog,
canvasGenerationDialogs,
getCanvasGenerationDialogsSnapshot,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
@@ -1159,11 +1160,12 @@ export function ImageCanvasEditorView({
layersRef,
viewportRef,
canvasGenerationDialogsRef,
getCanvasGenerationDialogsSnapshot,
canvasBackgroundColorRef,
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[],
[getCanvasGenerationDialogsSnapshot],
);
const projectPersistenceSetters = useMemo(
() => ({
@@ -1231,7 +1233,9 @@ export function ImageCanvasEditorView({
) => {
captureCanvasHistory(action);
applyProjectSnapshot(project);
if (action.type !== 'perfect-pixel') {
void refreshAssetLibrary();
}
},
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
);
@@ -1504,6 +1508,7 @@ export function ImageCanvasEditorView({
assetFolderId: activeUploadFolderId,
upsertGeneratedAsset,
applyProjectSnapshot: applyGeneratedProjectSnapshot,
applyProjectSnapshotWithoutHistory: applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
@@ -1627,6 +1632,7 @@ export function ImageCanvasEditorView({
snapSelectedLayerToPerfectPixels,
activeInlineGenerationDialogIdsRef,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
extractUiDesignAssets,
@@ -2413,6 +2419,7 @@ export function ImageCanvasEditorView({
generationComposerStyle,
selectedToolbarStyle,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splittingIconSpritesheetLayerIds,
persistingAssetKindLayerIds,
uploadDropTarget,
@@ -117,6 +117,109 @@ function renderComposer(
}
describe('ImageCanvasGenerationComposerView', () => {
it('待确认的完美像素操作只展示原操作重试,不回退到普通生成表单', () => {
const dialogId = 'dialog-perfect-pixel-pending';
const onRetryPerfectPixelOperation = vi.fn();
const onSubmitImageGeneration = vi.fn();
renderComposer(
{
id: dialogId,
mode: 'generate',
prompt: '不应重新提交的普通提示词',
status: 'pending-confirmation',
composerOpen: true,
imageModel: 'gpt-image-2',
perfectPixelOperation: {
version: 1,
kind: 'perfect-pixel',
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'ref:project-resource:resource-source',
projectId: 'project-1',
sourceResourceId: 'resource-source',
assetKind: 'character',
assetLabel: '角色 · 完美像素',
canvasCompletion: {
dialogId,
title: '角色 · 完美像素',
placeholder: {
x: 100,
y: 120,
width: 320,
height: 320,
originalWidth: 640,
originalHeight: 640,
},
},
},
submittedAt: 1_700_000_000_000,
reconcileUntil: 1_700_000_120_000,
},
},
{
onRetryPerfectPixelOperation,
onSubmitImageGeneration,
},
);
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
expect(within(panel).getByText('结果尚未确认,系统不会自动重复提交。'))
.toBeTruthy();
expect(within(panel).queryByRole('textbox')).toBeNull();
expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull();
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull();
const retryButton = within(panel).getByRole('button', {
name: '重试同一完美像素操作',
});
expect(within(panel).getAllByRole('button')).toEqual([retryButton]);
fireEvent.click(retryButton);
expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce();
expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId);
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
});
it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => {
const onRetryPerfectPixelOperation = vi.fn();
const onSubmitImageGeneration = vi.fn();
renderComposer(
{
id: 'dialog-perfect-pixel-invalid',
mode: 'generate',
prompt: '不得重新生成',
status: 'failed',
composerOpen: true,
imageModel: 'gpt-image-2',
perfectPixelOperationInvalid: true,
errorMessage: '完美像素操作快照身份不匹配。',
},
{
onRetryPerfectPixelOperation,
onSubmitImageGeneration,
},
);
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
expect(
within(panel).getByRole('alert').textContent,
).toBe('完美像素操作快照身份不匹配。');
expect(within(panel).queryByRole('textbox')).toBeNull();
expect(within(panel).queryByRole('button')).toBeNull();
expect(within(panel).queryByText('不得重新生成')).toBeNull();
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
fireEvent.submit(panel);
expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled();
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
});
it('让快速编辑显示提示词、尺寸和模型选择', () => {
renderComposer({
mode: 'quick-edit',
@@ -122,6 +122,7 @@ type ImageCanvasGenerationComposerViewProps = {
onSubmitQuickEdit: () => void;
onSubmitCropExpand: () => void;
onSubmitCharacterAnimation: () => void;
onRetryPerfectPixelOperation?: (dialogId: string) => void;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onUpdateIconDescriptionText: (value: string) => void;
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
@@ -130,6 +131,62 @@ type ImageCanvasGenerationComposerViewProps = {
onSpecMenuPointerLeave?: () => void;
};
function PerfectPixelOperationPanel({
dialog,
style,
onRetry,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
onRetry?: (dialogId: string) => void;
}) {
const isPending = dialog.status === 'pending-confirmation';
const isGenerating = dialog.status === 'generating';
const isInvalid = dialog.perfectPixelOperationInvalid === true;
const message = isInvalid
? (dialog.errorMessage ?? '完美像素操作快照无效,禁止自动重试。')
: isPending
? (dialog.errorMessage ??
'结果尚未确认,系统不会自动重复提交。')
: isGenerating
? '完美像素处理中'
: dialog.status === 'failed'
? (dialog.errorMessage ?? '完美像素处理失败')
: '完美像素处理已完成';
return (
<section
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--perfect-pixel"
style={style}
role="dialog"
aria-label="完美像素操作"
onPointerDown={(event) => event.stopPropagation()}
>
<strong></strong>
<PlatformStatusMessage
tone={isInvalid || dialog.status === 'failed' ? 'error' : 'info'}
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role={isInvalid || dialog.status === 'failed' ? 'alert' : 'status'}
>
{message}
</PlatformStatusMessage>
{isPending && dialog.id && dialog.perfectPixelOperation ? (
<PlatformActionButton
type="button"
size="sm"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={!onRetry}
onClick={() => onRetry?.(dialog.id!)}
>
</PlatformActionButton>
) : null}
</section>
);
}
function buildPortalMenuStyle(
anchor: HTMLElement | null,
placement: 'above' | 'below',
@@ -947,6 +1004,7 @@ export function ImageCanvasGenerationComposerView({
onSubmitQuickEdit,
onSubmitCropExpand,
onSubmitCharacterAnimation,
onRetryPerfectPixelOperation,
onUpdateSpecFormValue,
onUpdateIconDescriptionText,
onUpdateCharacterAnimationDuration,
@@ -954,6 +1012,10 @@ export function ImageCanvasGenerationComposerView({
onSpecMenuPointerEnter,
onSpecMenuPointerLeave,
}: ImageCanvasGenerationComposerViewProps) {
const isPerfectPixelDialog = Boolean(
generateDialog?.perfectPixelOperation ||
generateDialog?.perfectPixelOperationInvalid,
);
return (
<>
{isSpecMenuOpen
@@ -981,7 +1043,19 @@ export function ImageCanvasGenerationComposerView({
)
: null}
{(generateDialog?.mode === 'generate' ||
{isPerfectPixelDialog &&
generateDialog &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<PerfectPixelOperationPanel
dialog={generateDialog}
style={generationComposerStyle}
onRetry={onRetryPerfectPixelOperation}
/>
) : null}
{!isPerfectPixelDialog &&
(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'quick-edit') &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
@@ -78,7 +78,10 @@ function syncPanelFromDialogUpdate(
model: normalizedDialog.imageModel ?? normalizedPanel.model,
quickEditReferences: normalizedDialog.generationReferences,
assetLabel: normalizedDialog.assetLabel,
status: normalizedDialog.status,
status:
normalizedDialog.status === 'pending-confirmation'
? normalizedPanel.status
: normalizedDialog.status,
errorMessage: normalizedDialog.errorMessage,
};
}
@@ -44,6 +44,7 @@ function renderSelectedToolbar(
onRemoveBackground: vi.fn(),
onPerfectPixel: vi.fn(),
isPerfectPixelProcessing: false,
isPerfectPixelPendingConfirmation: false,
isSplittingIconSpritesheet: false,
isPersistingAssetKind: false,
onSplitIconSpritesheet: vi.fn(),
@@ -226,6 +227,29 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('renders a distinct disabled state while perfect pixel is pending confirmation', () => {
const props = renderSelectedToolbar({
isPerfectPixelPendingConfirmation: true,
});
const button = screen.getByRole('button', {
name: '完美像素结果待确认',
});
expect(button.getAttribute('title')).toBe(
'结果待确认,双击原占位继续核对或重试',
);
expect(button.getAttribute('aria-busy')).toBe('false');
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.textContent).toContain('待确认');
expect(button.textContent).not.toContain('处理中');
expect(button.querySelector('.lucide-grid-2x2')).toBeTruthy();
expect(button.querySelector('.lucide-loader-circle')).toBeNull();
fireEvent.click(button);
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('does not invoke perfect pixel while the selected asset kind is persisting', () => {
// 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时
// 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」
@@ -417,6 +441,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onRemoveBackground={vi.fn()}
onPerfectPixel={vi.fn()}
isPerfectPixelProcessing={false}
isPerfectPixelPendingConfirmation={false}
isSplittingIconSpritesheet={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
@@ -438,6 +463,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onRemoveBackground={vi.fn()}
onPerfectPixel={vi.fn()}
isPerfectPixelProcessing={false}
isPerfectPixelPendingConfirmation={false}
isSplittingIconSpritesheet={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
@@ -25,6 +25,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
onRemoveBackground: (layer: CanvasLayer) => void;
onPerfectPixel: (layer: CanvasLayer) => void;
isPerfectPixelProcessing: boolean;
isPerfectPixelPendingConfirmation: boolean;
isSplittingIconSpritesheet: boolean;
isPersistingAssetKind: boolean;
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
@@ -42,6 +43,7 @@ export function ImageCanvasSelectedLayerToolbarView({
onRemoveBackground,
onPerfectPixel,
isPerfectPixelProcessing,
isPerfectPixelPendingConfirmation,
isSplittingIconSpritesheet,
isPersistingAssetKind,
onSplitIconSpritesheet,
@@ -134,6 +136,8 @@ export function ImageCanvasSelectedLayerToolbarView({
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '完美像素结果待确认'
: '完美像素'
}
title={
@@ -141,6 +145,8 @@ export function ImageCanvasSelectedLayerToolbarView({
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '结果待确认,双击原占位继续核对或重试'
: '自动识别并规整像素网格'
}
icon={
@@ -154,7 +160,11 @@ export function ImageCanvasSelectedLayerToolbarView({
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={isPersistingAssetKind || isPerfectPixelProcessing}
disabled={
isPersistingAssetKind ||
isPerfectPixelProcessing ||
isPerfectPixelPendingConfirmation
}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
@@ -163,6 +173,8 @@ export function ImageCanvasSelectedLayerToolbarView({
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: isPerfectPixelPendingConfirmation
? '待确认'
: '完美像素'}
</span>
</PlatformIconButton>
@@ -79,6 +79,7 @@ export type ImageCanvasStageViewProps = {
selectedToolbarStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
perfectPixelLayerIds?: ReadonlySet<string>;
pendingPerfectPixelLayerIds?: ReadonlySet<string>;
persistingAssetKindLayerIds?: ReadonlySet<string>;
uploadDropTarget: 'canvas' | 'assets' | null;
contextMenu: CanvasContextMenuState | null;
@@ -235,6 +236,7 @@ export function ImageCanvasStageView({
selectedToolbarStyle,
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
perfectPixelLayerIds = EMPTY_LAYER_ID_SET,
pendingPerfectPixelLayerIds = EMPTY_LAYER_ID_SET,
persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET,
uploadDropTarget,
contextMenu,
@@ -409,6 +411,10 @@ export function ImageCanvasStageView({
isPerfectPixelProcessing={Boolean(
selectedLayer && perfectPixelLayerIds.has(selectedLayer.id),
)}
isPerfectPixelPendingConfirmation={Boolean(
selectedLayer &&
pendingPerfectPixelLayerIds.has(selectedLayer.id),
)}
onOpenQuickEditPanel={onOpenQuickEditPanel}
onOpenRedrawPanel={onOpenRedrawPanel}
onOpenCropExpandPanel={onOpenCropExpandPanel}
@@ -226,7 +226,8 @@ describe('ImageCanvasWorldView', () => {
useResolvedAssetReadUrlMock.mockImplementation(
(_source: string, options?: { objectKey?: string | null }) => ({
resolvedUrl:
options?.objectKey === 'generated-character-drafts/editor/uploaded.png'
options?.objectKey ===
'generated-character-drafts/editor/uploaded.png'
? 'https://oss.example.com/uploaded.png?signature=1'
: _source,
isResolving: false,
@@ -345,7 +346,9 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByText('生成中')).toBeTruthy();
const world = document.querySelector('.image-canvas-editor__world');
const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee');
const marquee = world?.querySelector(
'.image-canvas-editor__canvas-marquee',
);
expect(world?.getAttribute('style')).toContain(
'transform: translate(10px, 20px) scale(1.5)',
@@ -378,7 +381,11 @@ describe('ImageCanvasWorldView', () => {
expect(within(frame).getByText('Icon Generator')).toBeTruthy();
expect(within(frame).getByText('图标')).toBeTruthy();
expect(within(frame).getByText('1024 x 768')).toBeTruthy();
expect(within(frame).getByRole('status').textContent).toBe('生成中');
const generatingStatus = within(frame).getByRole('status');
expect(generatingStatus.textContent).toBe('生成中');
expect(generatingStatus.className).not.toContain(
'image-canvas-editor__generation-frame-progress--pending-confirmation',
);
fireEvent.pointerDown(frame);
fireEvent.doubleClick(frame);
@@ -394,6 +401,30 @@ describe('ImageCanvasWorldView', () => {
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
});
it('keeps a pending perfect-pixel placeholder visible with a confirmation verdict', () => {
const dialog = createGenerationDialog({
id: 'dialog-perfect-pixel-pending',
status: 'pending-confirmation',
});
renderWorldView({
canvasGenerationDialogs: [dialog],
generateDialog: null,
});
const frame = screen.getByRole('button', { name: '图像生成占位图' });
expect(frame.className).toContain(
'image-canvas-editor__generation-frame--pending-confirmation',
);
expect(within(frame).getByText('Image Generator')).toBeTruthy();
const pendingStatus = within(frame).getByRole('status');
expect(pendingStatus.textContent).toBe('结果待确认');
expect(pendingStatus.className).toContain(
'image-canvas-editor__generation-frame-progress--pending-confirmation',
);
expect(within(frame).queryByText('生成中')).toBeNull();
});
it('renders crop-expand frame handles and forwards drag actions', () => {
const layer = createLayer();
const { props } = renderWorldView({
@@ -530,7 +561,14 @@ describe('ImageCanvasWorldView', () => {
},
])(
'renders $mode generation placeholder with its own icon and badge',
({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => {
({
mode,
ariaLabel,
generatorLabel,
badgeLabel,
frameClassName,
iconClassName,
}) => {
const dialog = createGenerationDialog({
id: `dialog-${mode}`,
mode: mode as CanvasGenerationDialogState['mode'],
@@ -614,9 +652,9 @@ describe('ImageCanvasWorldView', () => {
const inverseScale = '4';
expect(
(within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText(generatorLabel) as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
mode === 'audio-sound-effect' || mode === 'audio-background-music'
@@ -630,9 +668,9 @@ describe('ImageCanvasWorldView', () => {
: inverseScale,
);
expect(
(within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText(badgeLabel) as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
},
);
@@ -718,14 +756,14 @@ describe('ImageCanvasWorldView', () => {
const inverseScale = '4';
expect(
(within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(layerButton).getByText('角色') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(layerButton).getByText('640 x 480 px') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
within(layerButton)
@@ -733,14 +771,14 @@ describe('ImageCanvasWorldView', () => {
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText('Image Generator') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText('1024 x 768') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
});
@@ -755,8 +793,11 @@ describe('ImageCanvasWorldView', () => {
const frame = screen.getByRole('button', { name: '图像生成占位图' });
expect(
(frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement)
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
(
frame.querySelector(
'.image-canvas-editor__generation-frame-icon',
) as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe('4');
});
@@ -813,9 +854,9 @@ describe('ImageCanvasWorldView', () => {
name: '查看角色主图图片信息',
});
expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain(
'lucide-info',
);
expect(
metadataButton.querySelector('svg')?.getAttribute('class'),
).toContain('lucide-info');
});
it('renders audio layers with an audio control card instead of an image', () => {
@@ -849,7 +890,8 @@ describe('ImageCanvasWorldView', () => {
within(layerButton).getAllByRole('button', { name: /|/u }),
).toHaveLength(1);
expect(
within(layerButton).getByRole('button', { name: '播放游戏音效' })
within(layerButton)
.getByRole('button', { name: '播放游戏音效' })
.querySelector('svg')
?.getAttribute('class'),
).toContain('lucide-volume-2');
@@ -872,9 +914,7 @@ describe('ImageCanvasWorldView', () => {
).toBeTruthy();
expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy();
expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy();
expect(
within(layerButton).queryByText('420 x 120 px'),
).toBeNull();
expect(within(layerButton).queryByText('420 x 120 px')).toBeNull();
});
it('keeps audio controls interactive and switches icon, play and pause states', () => {
@@ -895,7 +935,9 @@ describe('ImageCanvasWorldView', () => {
selectedLayerIds: [layer.id],
});
const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' });
const layerButton = screen.getByRole('button', {
name: '选择游戏背景音乐',
});
const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐');
const playbackButton = within(layerButton).getByRole('button', {
name: '播放游戏背景音乐',
@@ -932,9 +974,8 @@ describe('ImageCanvasWorldView', () => {
?.getAttribute('class'),
).toContain('lucide-play');
const hoveredAudio = within(hoveredLayerButton).getByLabelText(
'画布音频:游戏背景音乐',
);
const hoveredAudio =
within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐');
fireEvent.play(hoveredAudio);
expect(
within(hoveredLayerButton)
@@ -1032,9 +1073,7 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy();
fireEvent.pointerDown(document.body);
expect(
screen.queryByRole('menu', { name: '选择素材标签' }),
).toBeNull();
expect(screen.queryByRole('menu', { name: '选择素材标签' })).toBeNull();
} finally {
document.removeEventListener('wheel', documentWheel);
}
@@ -1100,7 +1139,9 @@ describe('ImageCanvasWorldView', () => {
layerButton.querySelector('.image-canvas-editor__media-preview--video')
?.className,
).toContain('image-canvas-editor__media-preview--variant-');
expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull();
expect(
within(layerButton).queryByAltText('画布视频:生成视频 7'),
).toBeNull();
expect(within(layerButton).getByText('视频')).toBeTruthy();
expect(props.onLayerPointerDown).toHaveBeenCalledWith(
expect.any(Object),
@@ -1201,8 +1242,7 @@ describe('ImageCanvasWorldView', () => {
it('keeps the last loaded sequence frame visible while the next frame is resolving', () => {
vi.useFakeTimers();
try {
useResolvedAssetReadUrlMock.mockImplementation(
(source: string) => ({
useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({
resolvedUrl:
source === '/generated-character-drafts/editor/frame01.png'
? 'https://oss.example.com/frame01.png?signature=1'
@@ -1210,8 +1250,7 @@ describe('ImageCanvasWorldView', () => {
isResolving:
source === '/generated-character-drafts/editor/frame02.png',
shouldResolve: true,
}),
);
}));
const layer = createLayer({
title: '角色动作',
src: '/generated-character-drafts/editor/frame01.png',
@@ -1240,9 +1279,8 @@ describe('ImageCanvasWorldView', () => {
renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择角色动作' });
const firstFrame = within(layerButton).getByAltText(
'画布序列帧:角色动作',
);
const firstFrame =
within(layerButton).getByAltText('画布序列帧:角色动作');
fireEvent.load(firstFrame);
expect(
@@ -1292,8 +1330,6 @@ describe('ImageCanvasWorldView', () => {
});
expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy();
expect(
screen.queryByRole('button', { name: '图像生成占位图' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '图像生成占位图' })).toBeNull();
});
});
@@ -316,9 +316,7 @@ function selectMediaLayerWithoutDrag(
onSelectLayer();
}
function handleLayerKeyboardActivation(
event: ReactKeyboardEvent<HTMLElement>,
) {
function handleLayerKeyboardActivation(event: ReactKeyboardEvent<HTMLElement>) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
@@ -364,7 +362,9 @@ function getAudioLayerIcon(layer: CanvasLayer) {
);
}
function getAudioDurationSeconds(durationSeconds: CanvasLayer['durationSeconds']) {
function getAudioDurationSeconds(
durationSeconds: CanvasLayer['durationSeconds'],
) {
return typeof durationSeconds === 'number' &&
Number.isFinite(durationSeconds) &&
durationSeconds > 0
@@ -485,7 +485,9 @@ function ImageCanvasAudioLayerCard({
event.stopPropagation();
void togglePlayback();
}}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
{showPlaybackButton ? (
isPlaying ? (
@@ -504,7 +506,9 @@ function ImageCanvasAudioLayerCard({
className="image-canvas-editor__audio-card-controls image-canvas-editor__audio-card-controls--integrated"
onClick={stopMediaEventPropagation}
onKeyDown={stopMediaControlKeyPropagation}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
<audio
ref={audioRef}
@@ -513,7 +517,9 @@ function ImageCanvasAudioLayerCard({
preload="metadata"
aria-label={`画布音频:${layer.title}`}
onClick={stopMediaEventPropagation}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
onLoadedMetadata={(event) => {
const nextDuration = event.currentTarget.duration;
setDuration(
@@ -522,7 +528,9 @@ function ImageCanvasAudioLayerCard({
: fallbackDuration,
);
}}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onTimeUpdate={(event) =>
setCurrentTime(event.currentTarget.currentTime)
}
onVolumeChange={(event) => {
setVolume(event.currentTarget.volume);
setIsMuted(event.currentTarget.muted);
@@ -556,7 +564,9 @@ function ImageCanvasAudioLayerCard({
event.stopPropagation();
toggleMute();
}}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
{isMuted ? (
<VolumeX className="h-3.5 w-3.5" aria-hidden="true" />
@@ -590,9 +600,12 @@ function ImageCanvasVideoLayer({
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(layer.thumbnailSrc, {
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(
layer.thumbnailSrc,
{
refreshKey: `${layer.taskId ?? layer.resourceId}:poster`,
});
},
);
const [showGeneratedPreview, setShowGeneratedPreview] = useState(
!layer.thumbnailSrc,
);
@@ -1242,7 +1255,10 @@ export function ImageCanvasWorldView({
getCanvasGenerationSelectionId(dialog.id),
);
const showFocusedChrome =
isFocused || isSelected || dialog.status === 'generating';
isFocused ||
isSelected ||
dialog.status === 'generating' ||
dialog.status === 'pending-confirmation';
const placeholderMeta = getGenerationPlaceholderMeta(dialog);
return (
@@ -1254,6 +1270,10 @@ export function ImageCanvasWorldView({
dialog.status === 'generating'
? 'image-canvas-editor__generation-frame--generating'
: ''
} ${
dialog.status === 'pending-confirmation'
? 'image-canvas-editor__generation-frame--pending-confirmation'
: ''
} ${
isFocused || isSelected
? 'image-canvas-editor__generation-frame--focused'
@@ -1321,6 +1341,14 @@ export function ImageCanvasWorldView({
</span>
) : null}
{dialog.status === 'pending-confirmation' ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__generation-frame-progress--pending-confirmation"
role="status"
>
</span>
) : null}
</div>
);
})()

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