支持画布编辑现有图片并保留原资源
禁用资源新增入口并将现有图片资源接入编辑画布 提示词精修生成新图片并保留原图、原素材与来源血缘 同步生成阶段草稿版本并支持鉴权失败后的原操作恢复 补齐草稿退出确认、键盘可访问性、回归测试与工程文档
This commit is contained in:
@@ -1513,6 +1513,45 @@ fn stage_asset_canvas_image_with_token_at(
|
||||
})
|
||||
}
|
||||
|
||||
fn rebind_asset_canvas_staged_image_revision_at(
|
||||
root: &Path,
|
||||
staged_image_token: &str,
|
||||
expected_project_id: &str,
|
||||
draft_id: &str,
|
||||
expected_previous_revision: u64,
|
||||
next_revision: u64,
|
||||
) -> Result<(), String> {
|
||||
validate_uuid_v4(staged_image_token, "stagedImageToken")?;
|
||||
validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?;
|
||||
validate_safe_revision(next_revision, "staging 新草稿 revision")?;
|
||||
validate_asset_canvas_project_identity(root, expected_project_id)?;
|
||||
let _lock = acquire_asset_canvas_draft_lock(root)?;
|
||||
let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?;
|
||||
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)?
|
||||
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
|
||||
if draft.revision != next_revision {
|
||||
return Err("staging 不能绑定到非当前草稿 revision".to_string());
|
||||
}
|
||||
let (mut metadata, _) = read_staged_image_locked(root, staged_image_token)?;
|
||||
if metadata.project_id != manifest.project_id || metadata.draft_id != draft_id {
|
||||
return Err("staging 图片与当前项目草稿身份不匹配".to_string());
|
||||
}
|
||||
if metadata.draft_revision == next_revision {
|
||||
return Ok(());
|
||||
}
|
||||
if metadata.draft_revision != expected_previous_revision {
|
||||
return Err("staging 图片 revision 已被其它流程推进".to_string());
|
||||
}
|
||||
metadata.draft_revision = next_revision;
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
&format!("{ASSET_CANVAS_ROOT}/staging/{staged_image_token}/metadata.json"),
|
||||
"素材画布 staging 元数据",
|
||||
&metadata,
|
||||
16 * 1024,
|
||||
)
|
||||
}
|
||||
|
||||
fn draft_media_relative_path(
|
||||
draft_id: &str,
|
||||
media_ref: &AssetCanvasMediaRef,
|
||||
|
||||
@@ -54,6 +54,7 @@ pub(crate) struct AssetCanvasGenerationProgressEvent {
|
||||
pub(crate) draft_id: String,
|
||||
pub(crate) intent_id: String,
|
||||
pub(crate) generation_id: String,
|
||||
pub(crate) draft_revision: u64,
|
||||
pub(crate) phase: String,
|
||||
pub(crate) progress: Option<u8>,
|
||||
pub(crate) error_code: Option<String>,
|
||||
@@ -191,6 +192,8 @@ struct AssetCanvasGenerationLedger {
|
||||
commit_idempotency_key: String,
|
||||
expected_host_revision: u64,
|
||||
expected_draft_revision: u64,
|
||||
#[serde(default)]
|
||||
current_draft_revision: Option<u64>,
|
||||
prompt: String,
|
||||
aspect_ratio: String,
|
||||
image_size: String,
|
||||
@@ -210,6 +213,8 @@ struct AssetCanvasGenerationLedger {
|
||||
poll_after_ms: Option<u64>,
|
||||
remote_result: Option<PrivateRemoteResult>,
|
||||
staged_image_token: String,
|
||||
#[serde(default)]
|
||||
staged_draft_revision: Option<u64>,
|
||||
commit_result: Option<PrivateCommitResult>,
|
||||
error_code: Option<String>,
|
||||
created_at: u64,
|
||||
@@ -332,6 +337,9 @@ fn generation_progress_event(
|
||||
draft_id: ledger.draft_id.clone(),
|
||||
intent_id: ledger.intent_id.clone(),
|
||||
generation_id: ledger.generation_id.clone(),
|
||||
draft_revision: ledger
|
||||
.current_draft_revision
|
||||
.unwrap_or(ledger.expected_draft_revision),
|
||||
phase: phase.to_string(),
|
||||
progress,
|
||||
error_code,
|
||||
@@ -402,6 +410,15 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<()
|
||||
}
|
||||
validate_safe_revision(ledger.expected_host_revision, "expectedHostRevision")?;
|
||||
validate_safe_revision(ledger.expected_draft_revision, "expectedDraftRevision")?;
|
||||
if let Some(revision) = ledger.current_draft_revision {
|
||||
validate_safe_revision(revision, "currentDraftRevision")?;
|
||||
if revision < ledger.expected_draft_revision {
|
||||
return Err("素材画布私有生成账本草稿 revision 倒退".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(revision) = ledger.staged_draft_revision {
|
||||
validate_safe_revision(revision, "stagedDraftRevision")?;
|
||||
}
|
||||
if ledger.prompt.is_empty()
|
||||
|| ledger.prompt.chars().count() > 32_000
|
||||
|| ledger.request_fingerprint.len() != 64
|
||||
@@ -484,7 +501,7 @@ fn set_private_phase(
|
||||
fn upsert_public_generation_record(
|
||||
root: &Path,
|
||||
ledger: &AssetCanvasGenerationLedger,
|
||||
) -> Result<AssetCanvasGenerationRecord, String> {
|
||||
) -> Result<(AssetCanvasGenerationRecord, u64), String> {
|
||||
let phase =
|
||||
public_phase(&ledger.phase).ok_or_else(|| "私有准备态不能投影到公开草稿".to_string())?;
|
||||
let _lock = acquire_asset_canvas_draft_lock(root)?;
|
||||
@@ -541,17 +558,38 @@ fn upsert_public_generation_record(
|
||||
} else if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted {
|
||||
draft.status = AssetCanvasDraftStatus::Generating;
|
||||
}
|
||||
draft.revision = draft
|
||||
.revision
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
|
||||
validate_safe_revision(draft.revision, "草稿 revision")?;
|
||||
draft.updated_at = now;
|
||||
write_asset_canvas_draft_locked(root, &draft)?;
|
||||
Ok(record)
|
||||
Ok((record, draft.revision))
|
||||
}
|
||||
|
||||
fn publish_public_phase(
|
||||
root: &Path,
|
||||
ledger: &AssetCanvasGenerationLedger,
|
||||
ledger: &mut AssetCanvasGenerationLedger,
|
||||
emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send),
|
||||
) -> Result<AssetCanvasGenerationRecord, String> {
|
||||
let record = upsert_public_generation_record(root, ledger)?;
|
||||
synchronize_staged_image_revision(root, ledger)?;
|
||||
let previous_staged_revision = ledger.staged_draft_revision;
|
||||
let (record, draft_revision) = upsert_public_generation_record(root, ledger)?;
|
||||
ledger.current_draft_revision = Some(draft_revision);
|
||||
write_generation_ledger(root, ledger)?;
|
||||
if let Some(previous_revision) = previous_staged_revision {
|
||||
rebind_asset_canvas_staged_image_revision_at(
|
||||
root,
|
||||
&ledger.staged_image_token,
|
||||
&ledger.project_id,
|
||||
&ledger.draft_id,
|
||||
previous_revision,
|
||||
draft_revision,
|
||||
)?;
|
||||
ledger.staged_draft_revision = Some(draft_revision);
|
||||
write_generation_ledger(root, ledger)?;
|
||||
}
|
||||
emit(generation_progress_event(
|
||||
ledger,
|
||||
public_phase_name(&record.phase),
|
||||
@@ -561,6 +599,40 @@ fn publish_public_phase(
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn synchronize_staged_image_revision(
|
||||
root: &Path,
|
||||
ledger: &mut AssetCanvasGenerationLedger,
|
||||
) -> Result<bool, String> {
|
||||
let (metadata, _) = match read_staged_image_locked(root, &ledger.staged_image_token) {
|
||||
Ok(staged) => staged,
|
||||
Err(error) if error == "素材画布 staging 元数据不存在" => return Ok(false),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if metadata.project_id != ledger.project_id || metadata.draft_id != ledger.draft_id {
|
||||
return Err("staging 图片与生成账本身份不匹配".to_string());
|
||||
}
|
||||
let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
|
||||
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
|
||||
if metadata.draft_revision != draft.revision {
|
||||
rebind_asset_canvas_staged_image_revision_at(
|
||||
root,
|
||||
&ledger.staged_image_token,
|
||||
&ledger.project_id,
|
||||
&ledger.draft_id,
|
||||
metadata.draft_revision,
|
||||
draft.revision,
|
||||
)?;
|
||||
}
|
||||
let changed = ledger.current_draft_revision != Some(draft.revision)
|
||||
|| ledger.staged_draft_revision != Some(draft.revision);
|
||||
ledger.current_draft_revision = Some(draft.revision);
|
||||
ledger.staged_draft_revision = Some(draft.revision);
|
||||
if changed {
|
||||
write_generation_ledger(root, ledger)?;
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn normalized_request_fingerprint(
|
||||
input: &GenerateAssetCanvasImageInput,
|
||||
manifest: &GameCreationAppManifest,
|
||||
@@ -738,6 +810,7 @@ fn validate_and_prepare_ledger(
|
||||
commit_idempotency_key: input.commit_idempotency_key.clone(),
|
||||
expected_host_revision: input.expected_host_revision,
|
||||
expected_draft_revision: input.expected_draft_revision,
|
||||
current_draft_revision: Some(input.expected_draft_revision),
|
||||
prompt: prompt.to_string(),
|
||||
aspect_ratio: input.aspect_ratio.clone(),
|
||||
image_size: input.image_size.clone(),
|
||||
@@ -756,6 +829,7 @@ fn validate_and_prepare_ledger(
|
||||
poll_after_ms: None,
|
||||
remote_result: None,
|
||||
staged_image_token: Uuid::new_v4().to_string(),
|
||||
staged_draft_revision: None,
|
||||
commit_result: None,
|
||||
error_code: None,
|
||||
created_at: now,
|
||||
@@ -1402,7 +1476,9 @@ fn committed_execution_from_private_result(
|
||||
project_id: committed.project_id.clone(),
|
||||
commit_id: committed.commit_id.clone(),
|
||||
committed_project_revision: committed.committed_project_revision,
|
||||
draft_revision: committed.draft_revision,
|
||||
draft_revision: ledger
|
||||
.current_draft_revision
|
||||
.unwrap_or(committed.draft_revision),
|
||||
host_revision: committed.host_revision.to_string(),
|
||||
commit_status: committed.commit_status.clone(),
|
||||
manifest,
|
||||
@@ -1456,6 +1532,14 @@ fn sanitized_generation_error(code: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitized_classified_generation_error(code: &str, reconciliation: bool) -> String {
|
||||
if reconciliation && code == "authentication-required" {
|
||||
return "reconciliation-required: 登录已失效;请重新登录后使用原 operation 恢复"
|
||||
.to_string();
|
||||
}
|
||||
sanitized_generation_error(code)
|
||||
}
|
||||
|
||||
fn should_poll_existing_operation(ledger: &AssetCanvasGenerationLedger) -> bool {
|
||||
matches!(
|
||||
ledger.phase,
|
||||
@@ -1637,7 +1721,7 @@ async fn classify_canvas_submit_error(
|
||||
|
||||
fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) {
|
||||
if error.contains("authentication-required") {
|
||||
return (false, "authentication-required");
|
||||
return (true, "authentication-required");
|
||||
}
|
||||
if error.contains("insufficient-mud-points") || error.contains("泥点余额不足") {
|
||||
return (false, "insufficient-mud-points");
|
||||
@@ -1652,6 +1736,16 @@ fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) {
|
||||
(false, "generation-failed")
|
||||
}
|
||||
|
||||
fn preserve_submit_reconciliation(
|
||||
phase: &GenerationLedgerPhase,
|
||||
code: &str,
|
||||
reconciliation: bool,
|
||||
) -> bool {
|
||||
reconciliation
|
||||
|| (code == "authentication-required"
|
||||
&& phase == &GenerationLedgerPhase::ReconciliationRequired)
|
||||
}
|
||||
|
||||
async fn reconcile_generation(
|
||||
root: &Path,
|
||||
mut ledger: AssetCanvasGenerationLedger,
|
||||
@@ -1770,8 +1864,10 @@ async fn reconcile_generation(
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let (code, reconciliation) = classify_canvas_submit_error(response, api_mode).await;
|
||||
let reconciliation =
|
||||
preserve_submit_reconciliation(&ledger.phase, code, reconciliation);
|
||||
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
|
||||
return Err(sanitized_generation_error(code));
|
||||
return Err(sanitized_classified_generation_error(code, reconciliation));
|
||||
}
|
||||
let submission = match response.json::<serde_json::Value>().await {
|
||||
Ok(value) => value,
|
||||
@@ -1783,7 +1879,7 @@ async fn reconcile_generation(
|
||||
match classify_canvas_generation_initial_response(status, &submission, api_mode) {
|
||||
Ok(CanvasGenerationInitialResponse::Completed(generated)) => {
|
||||
set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?;
|
||||
publish_public_phase(root, &ledger, emit)?;
|
||||
publish_public_phase(root, &mut ledger, emit)?;
|
||||
generated
|
||||
}
|
||||
Ok(CanvasGenerationInitialResponse::Async(submission)) => {
|
||||
@@ -1793,9 +1889,9 @@ async fn reconcile_generation(
|
||||
ledger.operation_id = Some(operation_id);
|
||||
ledger.poll_after_ms = Some(external_generation_poll_after_ms(&submission));
|
||||
set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?;
|
||||
publish_public_phase(root, &ledger, emit)?;
|
||||
publish_public_phase(root, &mut ledger, emit)?;
|
||||
set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?;
|
||||
publish_public_phase(root, &ledger, emit)?;
|
||||
publish_public_phase(root, &mut ledger, emit)?;
|
||||
match wait_for_canvas_generation_result(
|
||||
&client,
|
||||
api_base_url,
|
||||
@@ -1808,7 +1904,7 @@ async fn reconcile_generation(
|
||||
Err(error) => {
|
||||
let (reconciliation, code) = classify_canvas_generation_error(&error);
|
||||
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
|
||||
return Err(sanitized_generation_error(code));
|
||||
return Err(sanitized_classified_generation_error(code, reconciliation));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1827,14 +1923,14 @@ async fn reconcile_generation(
|
||||
"pollAfterMs": ledger.poll_after_ms.unwrap_or(2_000),
|
||||
});
|
||||
set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?;
|
||||
publish_public_phase(root, &ledger, emit)?;
|
||||
publish_public_phase(root, &mut ledger, emit)?;
|
||||
match wait_for_canvas_generation_result(&client, api_base_url, api_mode, &submission).await
|
||||
{
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
let (reconciliation, code) = classify_canvas_generation_error(&error);
|
||||
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
|
||||
return Err(sanitized_generation_error(code));
|
||||
return Err(sanitized_classified_generation_error(code, reconciliation));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1863,7 +1959,7 @@ async fn reconcile_generation(
|
||||
GenerationLedgerPhase::RemoteCompleted,
|
||||
None,
|
||||
)?;
|
||||
publish_public_phase(root, &ledger, emit)?;
|
||||
publish_public_phase(root, &mut ledger, emit)?;
|
||||
}
|
||||
|
||||
if matches!(
|
||||
@@ -1871,13 +1967,7 @@ async fn reconcile_generation(
|
||||
GenerationLedgerPhase::RemoteCompleted | GenerationLedgerPhase::ReconciliationRequired
|
||||
) && ledger.commit_result.is_none()
|
||||
{
|
||||
let staged_exists = read_staged_image_locked(root, &ledger.staged_image_token)
|
||||
.ok()
|
||||
.is_some_and(|(metadata, _)| {
|
||||
metadata.project_id == ledger.project_id
|
||||
&& metadata.draft_id == ledger.draft_id
|
||||
&& metadata.draft_revision == ledger.expected_draft_revision
|
||||
});
|
||||
let staged_exists = synchronize_staged_image_revision(root, &mut ledger)?;
|
||||
if !staged_exists {
|
||||
let remote = ledger
|
||||
.remote_result
|
||||
@@ -1921,7 +2011,9 @@ async fn reconcile_generation(
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: ledger.project_id.clone(),
|
||||
draft_id: ledger.draft_id.clone(),
|
||||
expected_draft_revision: ledger.expected_draft_revision,
|
||||
expected_draft_revision: ledger
|
||||
.current_draft_revision
|
||||
.unwrap_or(ledger.expected_draft_revision),
|
||||
media_type: download.media_type,
|
||||
bytes: download.bytes,
|
||||
},
|
||||
@@ -1939,6 +2031,9 @@ async fn reconcile_generation(
|
||||
"download-reconciliation-required",
|
||||
));
|
||||
}
|
||||
ledger.current_draft_revision = Some(staged.draft_revision);
|
||||
ledger.staged_draft_revision = Some(staged.draft_revision);
|
||||
write_generation_ledger(root, &mut ledger)?;
|
||||
}
|
||||
set_private_phase(
|
||||
root,
|
||||
@@ -1946,17 +2041,29 @@ async fn reconcile_generation(
|
||||
GenerationLedgerPhase::MediaDownloaded,
|
||||
None,
|
||||
)?;
|
||||
publish_public_phase(root, &ledger, emit)?;
|
||||
publish_public_phase(root, &mut ledger, emit)?;
|
||||
}
|
||||
|
||||
if ledger.phase == GenerationLedgerPhase::MediaDownloaded {
|
||||
if !synchronize_staged_image_revision(root, &mut ledger)? {
|
||||
mark_generation_error(
|
||||
root,
|
||||
&mut ledger,
|
||||
true,
|
||||
"commit-reconciliation-required",
|
||||
emit,
|
||||
)?;
|
||||
return Err(sanitized_generation_error("commit-reconciliation-required"));
|
||||
}
|
||||
let commit = commit_asset_canvas_at(
|
||||
root,
|
||||
&CommitAssetCanvasInput {
|
||||
project_path: root.to_string_lossy().into_owned(),
|
||||
expected_project_id: ledger.project_id.clone(),
|
||||
expected_revision: ledger.expected_host_revision,
|
||||
expected_draft_revision: ledger.expected_draft_revision,
|
||||
expected_draft_revision: ledger
|
||||
.current_draft_revision
|
||||
.unwrap_or(ledger.expected_draft_revision),
|
||||
draft_id: ledger.draft_id.clone(),
|
||||
commit_id: ledger.commit_id.clone(),
|
||||
idempotency_key: ledger.commit_idempotency_key.clone(),
|
||||
@@ -2042,6 +2149,7 @@ async fn reconcile_generation(
|
||||
return Err(sanitized_generation_error("commit-reconciliation-required"));
|
||||
}
|
||||
};
|
||||
ledger.current_draft_revision = Some(private_commit.draft_revision);
|
||||
ledger.commit_result = Some(private_commit);
|
||||
set_private_phase(
|
||||
root,
|
||||
@@ -2049,7 +2157,14 @@ async fn reconcile_generation(
|
||||
GenerationLedgerPhase::AssetDurableCommitted,
|
||||
None,
|
||||
)?;
|
||||
let generation = publish_public_phase(root, &ledger, emit)?;
|
||||
let generation = publish_public_phase(root, &mut ledger, emit)?;
|
||||
let final_draft_revision = ledger
|
||||
.current_draft_revision
|
||||
.expect("public commit projection must advance the draft revision");
|
||||
if let Some(committed) = ledger.commit_result.as_mut() {
|
||||
committed.draft_revision = final_draft_revision;
|
||||
}
|
||||
write_generation_ledger(root, &mut ledger)?;
|
||||
let committed = ledger
|
||||
.commit_result
|
||||
.as_ref()
|
||||
@@ -2065,7 +2180,7 @@ async fn reconcile_generation(
|
||||
project_id: committed.project_id.clone(),
|
||||
commit_id: committed.commit_id.clone(),
|
||||
committed_project_revision: committed.committed_project_revision,
|
||||
draft_revision: committed.draft_revision,
|
||||
draft_revision: final_draft_revision,
|
||||
host_revision: committed.host_revision.to_string(),
|
||||
commit_status: committed.commit_status.clone(),
|
||||
manifest,
|
||||
@@ -2357,6 +2472,7 @@ mod tests {
|
||||
commit_idempotency_key: Uuid::new_v4().to_string(),
|
||||
expected_host_revision: 0,
|
||||
expected_draft_revision: draft.revision,
|
||||
current_draft_revision: Some(draft.revision),
|
||||
prompt: "重启前已经提交的私有正文".to_string(),
|
||||
aspect_ratio: "1:1".to_string(),
|
||||
image_size: "1K".to_string(),
|
||||
@@ -2381,6 +2497,7 @@ mod tests {
|
||||
poll_after_ms: Some(0),
|
||||
remote_result: None,
|
||||
staged_image_token: Uuid::new_v4().to_string(),
|
||||
staged_draft_revision: None,
|
||||
commit_result: None,
|
||||
error_code: None,
|
||||
created_at: now,
|
||||
@@ -2504,6 +2621,27 @@ mod tests {
|
||||
assert!(progress
|
||||
.iter()
|
||||
.any(|event| event.phase == "asset-durable-committed"));
|
||||
assert!(progress
|
||||
.windows(2)
|
||||
.all(|events| events[0].draft_revision < events[1].draft_revision));
|
||||
assert_eq!(
|
||||
progress.last().map(|event| event.draft_revision),
|
||||
Some(first.result.commit.draft_revision)
|
||||
);
|
||||
let private = read_generation_ledger(directory.path(), &input.generation_id)
|
||||
.expect("read committed private ledger")
|
||||
.expect("committed private ledger exists");
|
||||
assert_eq!(
|
||||
private.current_draft_revision,
|
||||
Some(first.result.commit.draft_revision)
|
||||
);
|
||||
assert_eq!(
|
||||
private.staged_draft_revision,
|
||||
Some(first.result.commit.draft_revision)
|
||||
);
|
||||
let (staged, _) = read_staged_image_locked(directory.path(), &private.staged_image_token)
|
||||
.expect("read committed staging image");
|
||||
assert_eq!(staged.draft_revision, first.result.commit.draft_revision);
|
||||
|
||||
let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::<Vec<_>>();
|
||||
assert_eq!(requests.len(), 6);
|
||||
@@ -2716,6 +2854,7 @@ mod tests {
|
||||
commit_idempotency_key: Uuid::new_v4().to_string(),
|
||||
expected_host_revision: 0,
|
||||
expected_draft_revision: 0,
|
||||
current_draft_revision: Some(0),
|
||||
prompt: "精修提示词".to_string(),
|
||||
aspect_ratio: "1:1".to_string(),
|
||||
image_size: "1K".to_string(),
|
||||
@@ -2761,6 +2900,7 @@ mod tests {
|
||||
poll_after_ms: None,
|
||||
remote_result: None,
|
||||
staged_image_token: Uuid::new_v4().to_string(),
|
||||
staged_draft_revision: None,
|
||||
commit_result: None,
|
||||
error_code: None,
|
||||
created_at: now,
|
||||
@@ -2869,6 +3009,53 @@ mod tests {
|
||||
drop(directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_authentication_failure_stays_recoverable_with_the_original_operation() {
|
||||
let project_id = "phase-five-auth-reconciliation";
|
||||
let (directory, draft) = create_generation_fixture(project_id, "阶段五登录恢复语义测试");
|
||||
let mut ledger = accepted_ledger(
|
||||
project_id,
|
||||
&draft,
|
||||
"https://editor.example.test",
|
||||
"private-key",
|
||||
);
|
||||
let original_operation = ledger.operation_id.clone();
|
||||
let original_request = ledger.request_body_json.clone();
|
||||
let (reconciliation, code) =
|
||||
classify_canvas_generation_error("authentication-required: 登录已失效");
|
||||
assert!(reconciliation);
|
||||
assert_eq!(code, "authentication-required");
|
||||
let mut progress = Vec::new();
|
||||
mark_generation_error(
|
||||
directory.path(),
|
||||
&mut ledger,
|
||||
reconciliation,
|
||||
code,
|
||||
&mut |event| progress.push(event),
|
||||
)
|
||||
.expect("mark authentication reconciliation");
|
||||
|
||||
assert_eq!(ledger.phase, GenerationLedgerPhase::ReconciliationRequired);
|
||||
assert_eq!(ledger.operation_id, original_operation);
|
||||
assert_eq!(ledger.request_body_json, original_request);
|
||||
assert!(should_poll_existing_operation(&ledger));
|
||||
assert_eq!(progress.len(), 1);
|
||||
assert_eq!(progress[0].phase, "reconciliation-required");
|
||||
assert_eq!(progress[0].error_code.as_deref(), Some(code));
|
||||
assert!(sanitized_classified_generation_error(code, reconciliation)
|
||||
.starts_with("reconciliation-required:"));
|
||||
assert!(!preserve_submit_reconciliation(
|
||||
&GenerationLedgerPhase::Prepared,
|
||||
code,
|
||||
false,
|
||||
));
|
||||
assert!(preserve_submit_reconciliation(
|
||||
&GenerationLedgerPhase::ReconciliationRequired,
|
||||
code,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_access_token_is_ephemeral_and_never_enters_generation_ledger() {
|
||||
let project_id = "phase-five-authenticated-token";
|
||||
|
||||
@@ -93,6 +93,11 @@ export type AssetCanvasSaveAttempt = {
|
||||
commitId: string;
|
||||
};
|
||||
|
||||
export type AssetCanvasExitResult = {
|
||||
draftId: string;
|
||||
disposition: 'kept' | 'discarded';
|
||||
};
|
||||
|
||||
export type AssetCanvasCommitNotification = {
|
||||
source: 'command' | 'event';
|
||||
projectPath: string;
|
||||
@@ -323,7 +328,7 @@ export function AssetCanvasSurface({
|
||||
expectedHostRevision: string;
|
||||
initialAssetName?: string;
|
||||
initialAssetKind?: string;
|
||||
onCancel?: () => void;
|
||||
onCancel?: (result: AssetCanvasExitResult) => void;
|
||||
onCommitted?: (input: AssetCanvasCommitNotification) => void;
|
||||
onSaveAttempt?: (input: AssetCanvasSaveAttempt) => void;
|
||||
renderImage?: RenderAssetCanvasImage;
|
||||
@@ -369,6 +374,8 @@ export function AssetCanvasSurface({
|
||||
>('1K');
|
||||
const [generationReferenceResourceIds, setGenerationReferenceResourceIds] =
|
||||
useState<string[]>([]);
|
||||
const [exitDialogOpen, setExitDialogOpen] = useState(false);
|
||||
const [exitActionPending, setExitActionPending] = useState(false);
|
||||
const [documentVersion, setDocumentVersion] = useState(0);
|
||||
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
|
||||
const viewportElementRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -418,6 +425,26 @@ export function AssetCanvasSurface({
|
||||
setLifecycle({ kind: 'canvas.editing', dirty: true });
|
||||
}, []);
|
||||
|
||||
const applyGenerationProgressRevision = useCallback(
|
||||
(progress: ImageCanvasGenerationProgress) => {
|
||||
const revision = progress.draftRevision;
|
||||
const currentDraft = draftRef.current;
|
||||
if (
|
||||
typeof revision !== 'number' ||
|
||||
!Number.isSafeInteger(revision) ||
|
||||
revision < 0 ||
|
||||
!currentDraft ||
|
||||
revision <= currentDraft.revision
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextDraft = { ...currentDraft, revision };
|
||||
draftRef.current = nextDraft;
|
||||
setDraft(nextDraft);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const canvasHistoryRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
@@ -504,6 +531,7 @@ export function AssetCanvasSurface({
|
||||
}),
|
||||
);
|
||||
if (epoch !== epochRef.current) return;
|
||||
draftRef.current = nextDraft;
|
||||
setDraft(nextDraft);
|
||||
setLayers(runtimeLayers);
|
||||
setViewport(nextDraft.canvas.viewport);
|
||||
@@ -609,6 +637,7 @@ export function AssetCanvasSurface({
|
||||
epoch === epochRef.current &&
|
||||
recoveryFocusEpoch === generationFocusEpochRef.current
|
||||
) {
|
||||
applyGenerationProgressRevision(progress);
|
||||
setNotice(`正在恢复图片生成:${progress.phase}`);
|
||||
}
|
||||
},
|
||||
@@ -650,6 +679,7 @@ export function AssetCanvasSurface({
|
||||
previewUrls.clear();
|
||||
};
|
||||
}, [
|
||||
applyGenerationProgressRevision,
|
||||
expectedHostRevision,
|
||||
host,
|
||||
hydrateDraft,
|
||||
@@ -1057,11 +1087,12 @@ export function AssetCanvasSurface({
|
||||
stableScope,
|
||||
]);
|
||||
|
||||
const cancelCanvas = useCallback(() => {
|
||||
const discardCanvas = useCallback(() => {
|
||||
const currentDraft = draftRef.current;
|
||||
if (!currentDraft || lifecycleRef.current.kind === 'canvas.saving') {
|
||||
return;
|
||||
}
|
||||
setExitActionPending(true);
|
||||
const epoch = epochRef.current;
|
||||
void host.project
|
||||
.discardDraft({
|
||||
@@ -1071,7 +1102,11 @@ export function AssetCanvasSurface({
|
||||
.then((result) => {
|
||||
if (epoch !== epochRef.current) return;
|
||||
if (result.status === 'ok') {
|
||||
onCancel?.();
|
||||
setExitDialogOpen(false);
|
||||
onCancel?.({
|
||||
draftId: stableScope.draftId,
|
||||
disposition: 'discarded',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLifecycle({
|
||||
@@ -1092,9 +1127,54 @@ export function AssetCanvasSurface({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
reconciliationRequired: false,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (epoch === epochRef.current) setExitActionPending(false);
|
||||
});
|
||||
}, [host.project, onCancel, stableScope]);
|
||||
|
||||
const keepDraftAndExit = useCallback(() => {
|
||||
if (exitActionPending) return;
|
||||
const currentDraft = draftRef.current;
|
||||
if (!currentDraft) return;
|
||||
setExitActionPending(true);
|
||||
const epoch = epochRef.current;
|
||||
void persistDraft()
|
||||
.then((persisted) => {
|
||||
if (epoch !== epochRef.current || !persisted) return;
|
||||
setExitDialogOpen(false);
|
||||
onCancel?.({
|
||||
draftId: stableScope.draftId,
|
||||
disposition: 'kept',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
if (epoch === epochRef.current) setExitActionPending(false);
|
||||
});
|
||||
}, [exitActionPending, onCancel, persistDraft, stableScope.draftId]);
|
||||
|
||||
const requestCanvasExit = useCallback(() => {
|
||||
const currentDraft = draftRef.current;
|
||||
if (
|
||||
!currentDraft ||
|
||||
lifecycleRef.current.kind === 'canvas.saving' ||
|
||||
lifecycleRef.current.kind === 'canvas.generating'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
lifecycleRef.current.kind === 'canvas.editing' &&
|
||||
lifecycleRef.current.dirty
|
||||
) {
|
||||
setExitDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
onCancel?.({
|
||||
draftId: stableScope.draftId,
|
||||
disposition: 'kept',
|
||||
});
|
||||
}, [onCancel, stableScope.draftId]);
|
||||
|
||||
const openGenerationDialog = useCallback(() => {
|
||||
const currentDraft = draftRef.current;
|
||||
if (
|
||||
@@ -1191,6 +1271,7 @@ export function AssetCanvasSurface({
|
||||
progress.intentId === identity.intentId &&
|
||||
progress.generationId === identity.generationId
|
||||
) {
|
||||
applyGenerationProgressRevision(progress);
|
||||
setLifecycle({ kind: 'canvas.generating', phase: progress.phase });
|
||||
setNotice(progress.errorCode ?? '');
|
||||
}
|
||||
@@ -1287,6 +1368,7 @@ export function AssetCanvasSurface({
|
||||
}
|
||||
});
|
||||
}, [
|
||||
applyGenerationProgressRevision,
|
||||
assetKind,
|
||||
assetName,
|
||||
generationAspectRatio,
|
||||
@@ -1327,8 +1409,11 @@ export function AssetCanvasSurface({
|
||||
pendingGenerationRef.current = null;
|
||||
setGenerationDialog(null);
|
||||
setNotice('生成仍会在后台使用原 operation 安全对账');
|
||||
onCancel?.();
|
||||
}, [onCancel]);
|
||||
onCancel?.({
|
||||
draftId: stableScope.draftId,
|
||||
disposition: 'kept',
|
||||
});
|
||||
}, [onCancel, stableScope.draftId]);
|
||||
|
||||
const minimapModel = useMemo(
|
||||
() => createMinimapModel({ layers, viewport, canvasSize }),
|
||||
@@ -1379,7 +1464,7 @@ export function AssetCanvasSurface({
|
||||
onClick={
|
||||
lifecycle.kind === 'canvas.generating'
|
||||
? stopWaitingForGeneration
|
||||
: cancelCanvas
|
||||
: requestCanvasExit
|
||||
}
|
||||
disabled={lifecycle.kind === 'canvas.saving'}
|
||||
>
|
||||
@@ -1678,6 +1763,54 @@ export function AssetCanvasSurface({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{exitDialogOpen ? (
|
||||
<div
|
||||
className="asset-canvas-surface__dialog-backdrop"
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
className="asset-canvas-surface__generation-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="asset-canvas-exit-title"
|
||||
>
|
||||
<header>
|
||||
<strong id="asset-canvas-exit-title">返回资源总览</strong>
|
||||
</header>
|
||||
<div className="asset-canvas-surface__generation-confirmation">
|
||||
<p>当前画布有尚未完成的编辑,请选择是否保留草稿。</p>
|
||||
<footer>
|
||||
<CanvasChromeButton
|
||||
label="放弃草稿"
|
||||
icon={<Trash2 size={15} aria-hidden="true" />}
|
||||
onClick={discardCanvas}
|
||||
disabled={exitActionPending}
|
||||
>
|
||||
放弃草稿
|
||||
</CanvasChromeButton>
|
||||
<CanvasChromeButton
|
||||
label="继续编辑"
|
||||
icon={<ArrowLeft size={15} aria-hidden="true" />}
|
||||
onClick={() => setExitDialogOpen(false)}
|
||||
disabled={exitActionPending}
|
||||
>
|
||||
继续编辑
|
||||
</CanvasChromeButton>
|
||||
<CanvasChromeButton
|
||||
label="保留草稿并退出"
|
||||
icon={<Save size={15} aria-hidden="true" />}
|
||||
className="asset-canvas-surface__primary-action"
|
||||
onClick={keepDraftAndExit}
|
||||
disabled={exitActionPending}
|
||||
>
|
||||
保留草稿并退出
|
||||
</CanvasChromeButton>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lifecycle.kind === 'canvas.generating' ? (
|
||||
<section
|
||||
className="asset-canvas-surface__operation-overlay"
|
||||
@@ -1722,7 +1855,7 @@ export function AssetCanvasSurface({
|
||||
<CanvasChromeButton
|
||||
label="返回资源总览"
|
||||
icon={<ArrowLeft size={15} aria-hidden="true" />}
|
||||
onClick={cancelCanvas}
|
||||
onClick={requestCanvasExit}
|
||||
>
|
||||
返回资源总览
|
||||
</CanvasChromeButton>
|
||||
@@ -1791,60 +1924,84 @@ export function AssetCanvasSurface({
|
||||
return (
|
||||
<LayerRenderer
|
||||
key={layer.id}
|
||||
as="div"
|
||||
layer={layer}
|
||||
selected={selected}
|
||||
role="group"
|
||||
aria-label={`图层 ${layer.title}`}
|
||||
onPointerDown={(event: ReactPointerEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
const targetIds = event.shiftKey
|
||||
? selected
|
||||
? selectedLayerIds.filter((id) => id !== layer.id)
|
||||
: [...selectedLayerIds, layer.id]
|
||||
: selected
|
||||
? selectedLayerIds
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="asset-canvas-surface__layer-hit-target"
|
||||
aria-label={`选择图层 ${layer.title}`}
|
||||
aria-pressed={selected}
|
||||
onClick={(event) => {
|
||||
const targetIds = event.shiftKey
|
||||
? selected
|
||||
? selectedLayerIds.filter((id) => id !== layer.id)
|
||||
: [...selectedLayerIds, layer.id]
|
||||
: [layer.id];
|
||||
setSelectedLayerIds(targetIds);
|
||||
if (layer.locked) {
|
||||
if (
|
||||
targetIds.length !== selectedLayerIds.length ||
|
||||
targetIds.some(
|
||||
(id, index) => id !== selectedLayerIds[index],
|
||||
)
|
||||
) {
|
||||
setSelectedLayerIds(targetIds);
|
||||
markDirty();
|
||||
}
|
||||
return;
|
||||
}
|
||||
captureHistory({
|
||||
type: 'move-image',
|
||||
count: targetIds.length,
|
||||
layerIds: targetIds,
|
||||
});
|
||||
dragRef.current = {
|
||||
kind: 'move',
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startLayers: layersRef.current.map((item) => ({ ...item })),
|
||||
targetIds,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={layer.src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
transform: `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
onPointerDown={(event: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
const targetIds = event.shiftKey
|
||||
? selected
|
||||
? selectedLayerIds.filter((id) => id !== layer.id)
|
||||
: [...selectedLayerIds, layer.id]
|
||||
: selected
|
||||
? selectedLayerIds
|
||||
: [layer.id];
|
||||
setSelectedLayerIds(targetIds);
|
||||
if (layer.locked) {
|
||||
if (
|
||||
targetIds.length !== selectedLayerIds.length ||
|
||||
targetIds.some(
|
||||
(id, index) => id !== selectedLayerIds[index],
|
||||
)
|
||||
) {
|
||||
markDirty();
|
||||
}
|
||||
return;
|
||||
}
|
||||
captureHistory({
|
||||
type: 'move-image',
|
||||
count: targetIds.length,
|
||||
layerIds: targetIds,
|
||||
});
|
||||
dragRef.current = {
|
||||
kind: 'move',
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startLayers: layersRef.current.map((item) => ({ ...item })),
|
||||
targetIds,
|
||||
};
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={layer.src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
transform: `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{selected && !layer.locked ? (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`缩放图层 ${layer.title}`}
|
||||
className="asset-canvas-surface__resize-handle"
|
||||
onPointerDown={(event) => {
|
||||
@@ -1864,6 +2021,35 @@ export function AssetCanvasSurface({
|
||||
layerId: layer.id,
|
||||
};
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!event.key.startsWith('Arrow')) return;
|
||||
event.preventDefault();
|
||||
const direction =
|
||||
event.key === 'ArrowRight' || event.key === 'ArrowDown'
|
||||
? 1
|
||||
: -1;
|
||||
const delta = direction * (event.shiftKey ? 10 : 1);
|
||||
captureHistory({
|
||||
type: 'resize-image',
|
||||
count: 1,
|
||||
layerIds: [layer.id],
|
||||
});
|
||||
const resized = resizeCanvasLayerBounds({
|
||||
initial: layer,
|
||||
handle: 'bottom-right',
|
||||
deltaX: delta,
|
||||
deltaY: delta,
|
||||
preserveAspectRatio: true,
|
||||
minSize: 16,
|
||||
});
|
||||
setLayers((current) =>
|
||||
transformCanvasLayers(
|
||||
current,
|
||||
new Map([[layer.id, resized]]),
|
||||
) as RuntimeCanvasLayer[],
|
||||
);
|
||||
markDirty();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</LayerRenderer>
|
||||
|
||||
@@ -333,6 +333,24 @@
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.asset-canvas-surface__layer-hit-target {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.asset-canvas-surface__layer-hit-target:focus-visible,
|
||||
.asset-canvas-surface__resize-handle:focus-visible {
|
||||
outline: 3px solid var(--platform-focus-ring);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.asset-canvas-surface__viewport-tools {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
|
||||
@@ -255,6 +255,7 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
callback({
|
||||
intentId: progress.intentId,
|
||||
generationId: progress.generationId,
|
||||
draftRevision: progress.draftRevision,
|
||||
phase: progress.phase,
|
||||
progress: progress.progress,
|
||||
errorCode: progress.errorCode,
|
||||
|
||||
@@ -40,6 +40,7 @@ import type {
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
type AssetCanvasCommitNotification,
|
||||
type AssetCanvasExitResult,
|
||||
type AssetCanvasSaveAttempt,
|
||||
AssetCanvasSurface,
|
||||
} from '../../features/asset-canvas/AssetCanvasSurface';
|
||||
@@ -498,6 +499,7 @@ export default function ProjectDevelopmentView({
|
||||
const focusedCommitIdsRef = useRef(new Set<string>());
|
||||
const activeFocusFlowIdRef = useRef<string | null>(null);
|
||||
const assetCanvasRouteRef = useRef<AssetCanvasRoute | null>(null);
|
||||
const resumableRefineDraftsRef = useRef(new Map<string, string>());
|
||||
const dependencyDescriptionId = useId();
|
||||
|
||||
assetCanvasRouteRef.current = assetCanvasRoute;
|
||||
@@ -1168,21 +1170,30 @@ export default function ProjectDevelopmentView({
|
||||
);
|
||||
|
||||
const openAssetCanvas = useCallback(
|
||||
(intent: 'create' | 'refine', sourceAssetId: string | null) => {
|
||||
(sourceAssetId: string) => {
|
||||
const invoke = window.__TAURI__?.core?.invoke;
|
||||
if (!invoke) {
|
||||
setAssetCanvasNotice('素材创作画布需要在客户端内打开');
|
||||
return;
|
||||
}
|
||||
const sourceAsset = manifest.assets.find(
|
||||
(asset) => asset.id === sourceAssetId,
|
||||
);
|
||||
if (
|
||||
!sourceAsset ||
|
||||
!['image/png', 'image/jpeg', 'image/webp'].includes(
|
||||
sourceAsset.mediaType,
|
||||
)
|
||||
) {
|
||||
setAssetCanvasNotice('只能编辑当前项目中已存在的图片资源');
|
||||
return;
|
||||
}
|
||||
const openEpoch = canvasOpenEpochRef.current + 1;
|
||||
canvasOpenEpochRef.current = openEpoch;
|
||||
advanceFocusGeneration();
|
||||
pendingResourceFocusRef.current = null;
|
||||
setHiddenCommittedResourceId(null);
|
||||
setAssetCanvasNotice('正在打开素材创作画布…');
|
||||
const sourceAsset = sourceAssetId
|
||||
? manifest.assets.find((asset) => asset.id === sourceAssetId)
|
||||
: null;
|
||||
void invoke<{ revision: number }>('get_local_game_project_revision', {
|
||||
projectPath,
|
||||
})
|
||||
@@ -1198,8 +1209,10 @@ export default function ProjectDevelopmentView({
|
||||
const sessionId = crypto.randomUUID();
|
||||
const scope: ImageCanvasHostScope = {
|
||||
projectId: manifest.projectId,
|
||||
draftId: crypto.randomUUID(),
|
||||
intent,
|
||||
draftId:
|
||||
resumableRefineDraftsRef.current.get(sourceAssetId) ??
|
||||
crypto.randomUUID(),
|
||||
intent: 'refine',
|
||||
sourceAssetId,
|
||||
};
|
||||
activeFocusFlowIdRef.current = flowId;
|
||||
@@ -1207,14 +1220,8 @@ export default function ProjectDevelopmentView({
|
||||
flowId,
|
||||
sessionId,
|
||||
expectedHostRevision: String(status.revision),
|
||||
initialAssetName:
|
||||
intent === 'refine' && sourceAsset
|
||||
? assetNameFromLocalPath(sourceAsset.localPath)
|
||||
: '画布素材',
|
||||
initialAssetKind:
|
||||
intent === 'refine' && sourceAsset
|
||||
? sourceAsset.kind
|
||||
: 'game-art',
|
||||
initialAssetName: assetNameFromLocalPath(sourceAsset.localPath),
|
||||
initialAssetKind: sourceAsset.kind,
|
||||
scope,
|
||||
host: createTauriImageCanvasHostAdapter({
|
||||
projectPath,
|
||||
@@ -1236,7 +1243,16 @@ export default function ProjectDevelopmentView({
|
||||
[advanceFocusGeneration, manifest.assets, manifest.projectId, projectPath],
|
||||
);
|
||||
|
||||
const cancelAssetCanvas = useCallback(() => {
|
||||
const cancelAssetCanvas = useCallback((result: AssetCanvasExitResult) => {
|
||||
const route = assetCanvasRouteRef.current;
|
||||
const sourceAssetId = route?.scope.sourceAssetId;
|
||||
if (sourceAssetId) {
|
||||
if (result.disposition === 'kept') {
|
||||
resumableRefineDraftsRef.current.set(sourceAssetId, result.draftId);
|
||||
} else {
|
||||
resumableRefineDraftsRef.current.delete(sourceAssetId);
|
||||
}
|
||||
}
|
||||
canvasOpenEpochRef.current += 1;
|
||||
advanceFocusGeneration();
|
||||
activeFocusFlowIdRef.current = null;
|
||||
@@ -1307,6 +1323,9 @@ export default function ProjectDevelopmentView({
|
||||
...focusIntent,
|
||||
resourceId: `asset:${notification.assetId}`,
|
||||
};
|
||||
if (route.scope.sourceAssetId) {
|
||||
resumableRefineDraftsRef.current.delete(route.scope.sourceAssetId);
|
||||
}
|
||||
setAssetCanvasRoute(null);
|
||||
setMode('resources');
|
||||
setFocusedResourceId(null);
|
||||
@@ -1518,7 +1537,8 @@ export default function ProjectDevelopmentView({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openAssetCanvas('create', null)}
|
||||
disabled
|
||||
title="新增资源暂未开放,请先选择现有图片进行编辑"
|
||||
>
|
||||
<Sparkles size={15} aria-hidden="true" />
|
||||
新增资源
|
||||
@@ -1604,11 +1624,9 @@ export default function ProjectDevelopmentView({
|
||||
{focusedResourceIsImage && focusedResource.manifestAssetId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openAssetCanvas('refine', focusedResource.manifestAssetId)
|
||||
}
|
||||
onClick={() => openAssetCanvas(focusedResource.manifestAssetId!)}
|
||||
>
|
||||
精修资源
|
||||
编辑资源
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -61,6 +62,38 @@ function emptyCanvas(): ImageCanvasDraftCanvas {
|
||||
};
|
||||
}
|
||||
|
||||
function keyboardCanvas(): ImageCanvasDraftCanvas {
|
||||
return {
|
||||
...emptyCanvas(),
|
||||
layers: ['第一层', '第二层'].map((title, index) => ({
|
||||
layerId: `keyboard-layer-${index + 1}`,
|
||||
resourceId: `draft-media:keyboard-${index + 1}`,
|
||||
title,
|
||||
mediaRef: {
|
||||
kind: 'draft-media' as const,
|
||||
mediaId: `keyboard-${index + 1}`,
|
||||
mediaType: 'image/png' as const,
|
||||
sha256: String(index + 1).repeat(64),
|
||||
byteLength: 4,
|
||||
pixelWidth: 40,
|
||||
pixelHeight: 30,
|
||||
},
|
||||
x: index * 60,
|
||||
y: 0,
|
||||
width: 40,
|
||||
height: 30,
|
||||
originalWidth: 40,
|
||||
originalHeight: 30,
|
||||
zIndex: index,
|
||||
groupId: null,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function draftFixture(
|
||||
draftScope: ImageCanvasHostScope = scope,
|
||||
canvas: ImageCanvasDraftCanvas = emptyCanvas(),
|
||||
@@ -160,6 +193,11 @@ function memoryHost(input?: {
|
||||
value: { projectRevision: hostRevision, manifest },
|
||||
};
|
||||
});
|
||||
const discardDraft = vi.fn(async () => {
|
||||
if (!draft) throw new Error('draft missing');
|
||||
draft = { ...draft, revision: draft.revision + 1, status: 'cancelled' };
|
||||
return { status: 'ok' as const, value: draft };
|
||||
});
|
||||
const host: TauriImageCanvasHostAdapter = {
|
||||
kind: 'tauri',
|
||||
projectPath: '/fixture/project',
|
||||
@@ -198,11 +236,7 @@ function memoryHost(input?: {
|
||||
};
|
||||
return { status: 'ok', value: draft };
|
||||
},
|
||||
async discardDraft() {
|
||||
if (!draft) throw new Error('draft missing');
|
||||
draft = { ...draft, revision: draft.revision + 1, status: 'cancelled' };
|
||||
return { status: 'ok', value: draft };
|
||||
},
|
||||
discardDraft,
|
||||
},
|
||||
asset: {
|
||||
async importImages(importInput) {
|
||||
@@ -362,6 +396,7 @@ function memoryHost(input?: {
|
||||
revokedSubscriptions,
|
||||
loadDraft,
|
||||
recover,
|
||||
discardDraft,
|
||||
getDraft: () => draft,
|
||||
emit(event: LocalAssetCommittedEvent) {
|
||||
eventListener?.(event);
|
||||
@@ -592,15 +627,97 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('取消时先把草稿标记为 cancelled,再返回资源总览', async () => {
|
||||
it('无未保存修改时保留草稿并直接返回资源总览', async () => {
|
||||
const memory = memoryHost();
|
||||
const { onCancel } = renderSurface(memory.host);
|
||||
expect(await screen.findByText('画布可编辑')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1));
|
||||
expect(onCancel).toHaveBeenCalledWith({
|
||||
draftId: scope.draftId,
|
||||
disposition: 'kept',
|
||||
});
|
||||
expect(memory.discardDraft).not.toHaveBeenCalled();
|
||||
expect(memory.getDraft()?.status).toBe('editing');
|
||||
});
|
||||
|
||||
it('有未保存修改时使用独立确认面板,并可保存草稿后退出', async () => {
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
});
|
||||
const { onCancel } = renderSurface(memory.host);
|
||||
const layer = await screen.findByRole('button', {
|
||||
name: '选择图层 第一层',
|
||||
});
|
||||
fireEvent.click(layer);
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: '返回资源总览' }),
|
||||
).toBeTruthy();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '保留草稿并退出' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onCancel).toHaveBeenCalledWith({
|
||||
draftId: scope.draftId,
|
||||
disposition: 'kept',
|
||||
}),
|
||||
);
|
||||
expect(memory.updates.length).toBeGreaterThan(0);
|
||||
expect(memory.discardDraft).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('只有明确放弃草稿时才写入 cancelled', async () => {
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
});
|
||||
const { onCancel } = renderSurface(memory.host);
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '选择图层 第一层' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃草稿' }));
|
||||
|
||||
await waitFor(() => expect(memory.discardDraft).toHaveBeenCalledTimes(1));
|
||||
expect(onCancel).toHaveBeenCalledWith({
|
||||
draftId: scope.draftId,
|
||||
disposition: 'discarded',
|
||||
});
|
||||
expect(memory.getDraft()?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('原生图层按钮支持 Enter 和空格选择,缩放按钮支持方向键', async () => {
|
||||
const user = userEvent.setup();
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
});
|
||||
renderSurface(memory.host);
|
||||
const first = await screen.findByRole('button', {
|
||||
name: '选择图层 第一层',
|
||||
});
|
||||
const second = screen.getByRole('button', { name: '选择图层 第二层' });
|
||||
|
||||
first.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(first.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
second.focus();
|
||||
await user.keyboard(' ');
|
||||
expect(second.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(first.getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
const resize = screen.getByRole('button', { name: '缩放图层 第二层' });
|
||||
resize.focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
await waitFor(() => {
|
||||
const resized = memory
|
||||
.getDraft()
|
||||
?.canvas.layers.find((layer) => layer.layerId === 'keyboard-layer-2');
|
||||
expect(resized?.width).toBeGreaterThan(40);
|
||||
});
|
||||
});
|
||||
|
||||
it('新建、导入、编辑、撤销重做并完成 durable commit', async () => {
|
||||
const memory = memoryHost();
|
||||
const { onCommitted, onSaveAttempt } = renderSurface(memory.host);
|
||||
@@ -982,6 +1099,10 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
screen.getAllByRole('button', { name: '停止等待并返回' }).at(-1)!,
|
||||
);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onCancel).toHaveBeenCalledWith({
|
||||
draftId: scope.draftId,
|
||||
disposition: 'kept',
|
||||
});
|
||||
|
||||
await act(async () => gate.resolve());
|
||||
await act(async () => Promise.resolve());
|
||||
@@ -1060,7 +1181,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
const memory = memoryHost({ initialDraft: draftFixture(scope, canvas) });
|
||||
const onCommitted = vi.fn();
|
||||
const view = renderSurface(memory.host, { ...scope }, onCommitted);
|
||||
const layer = await screen.findByLabelText('图层 锁定图层');
|
||||
const layer = await screen.findByRole('button', {
|
||||
name: '选择图层 锁定图层',
|
||||
});
|
||||
expect(memory.recover).toHaveBeenCalledTimes(1);
|
||||
expect(memory.loadDraft).toHaveBeenCalledTimes(1);
|
||||
view.rerender(
|
||||
|
||||
@@ -23,7 +23,10 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
|
||||
sessionId: string;
|
||||
initialAssetName?: string;
|
||||
initialAssetKind?: string;
|
||||
onCancel?: () => void;
|
||||
onCancel?: (result: {
|
||||
draftId: string;
|
||||
disposition: 'kept' | 'discarded';
|
||||
}) => void;
|
||||
onSaveAttempt?: (attempt: {
|
||||
saveAttemptId: string;
|
||||
sessionId: string;
|
||||
@@ -43,7 +46,14 @@ vi.mock('../src/features/asset-canvas/AssetCanvasSurface', async () => {
|
||||
),
|
||||
ReactModule.createElement(
|
||||
'button',
|
||||
{ type: 'button', onClick: props.onCancel },
|
||||
{
|
||||
type: 'button',
|
||||
onClick: () =>
|
||||
props.onCancel?.({
|
||||
draftId: props.scope.draftId,
|
||||
disposition: 'kept',
|
||||
}),
|
||||
},
|
||||
'取消并返回',
|
||||
),
|
||||
ReactModule.createElement(
|
||||
@@ -266,15 +276,20 @@ describe('project resource live canvas integration', () => {
|
||||
return { graphReads, layoutWrites };
|
||||
}
|
||||
|
||||
it('enters create/refine in the central view, cancels to context, and coordinates both layouts after a durable refine', async () => {
|
||||
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
|
||||
const { graphReads, layoutWrites } = installTauri();
|
||||
render(<LiveWorkbench />);
|
||||
|
||||
const sourceCard = await screen.findByRole('button', {
|
||||
name: /source-art\.png/,
|
||||
});
|
||||
expect(
|
||||
(screen.getByRole('button', {
|
||||
name: '新增资源',
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
fireEvent.click(sourceCard);
|
||||
fireEvent.click(screen.getByRole('button', { name: '精修资源' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
expect(
|
||||
(await screen.findByLabelText('测试素材创作画布')).textContent,
|
||||
).toContain('refine:source-art:source-art:art-image');
|
||||
@@ -283,7 +298,7 @@ describe('project resource live canvas integration', () => {
|
||||
await screen.findByRole('region', { name: /source-art\.png/ }),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '精修资源' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '完成测试保存' }),
|
||||
);
|
||||
@@ -291,6 +306,9 @@ describe('project resource live canvas integration', () => {
|
||||
await screen.findByRole('region', { name: /canvas-output-1\.png/ }),
|
||||
).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /source-art\.png/ }),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
await screen.findByText(/canvas-output-1\.png 引用 source-art\.png/),
|
||||
).not.toBeNull();
|
||||
@@ -328,12 +346,15 @@ describe('project resource live canvas integration', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an existing search when the new resource is hidden and locates only after the explicit action', async () => {
|
||||
it('keeps an existing search when an edited result is hidden and locates only after the explicit action', async () => {
|
||||
installTauri();
|
||||
render(<LiveWorkbench />);
|
||||
const search = await screen.findByLabelText('搜索项目资源');
|
||||
fireEvent.change(search, { target: { value: 'source-art' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '新增资源' }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: /source-art\.png/ }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '完成测试保存' }),
|
||||
);
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
### 3.7 主站 UI 对齐与共享视觉边界
|
||||
|
||||
实现状态(2026-08-06):主站与 Tauri 已完成同源 chrome 接入。Tauri 现有中央素材画布直接消费共享动作按钮、工具栏、工具组和分隔符;工作台外围继续保留四区结构,并以平台 token 统一中央壳、Supervisor、Agent Dock、状态提示和主要操作。生成、保存、登录、计费、草稿、manifest、Runtime 和审批语义未随换肤改变。
|
||||
实现状态(2026-08-10):主站与 Tauri 已完成同源 chrome 接入。Tauri 现有中央素材画布直接消费共享动作按钮、工具栏、工具组和分隔符;工作台外围继续保留四区结构,并以平台 token 统一中央壳、Supervisor、Agent Dock、状态提示和主要操作。当前普通用户入口临时收敛为仅编辑现有图片:“新增资源”不可进入 create,图片“编辑资源”继续使用 refine 非破坏性生成新 asset。生成、保存、登录、计费、草稿、manifest、Runtime 和审批语义未随入口收敛而改变。
|
||||
|
||||
- 项目工作台继续保留左侧平台导航、中央主视窗、右侧 Project Supervisor 和底部专业 Agent 状态栏四区结构;主站图片编辑器只作为视觉语言和共享画布组件的事实源,不把其素材库侧栏、账号业务或云端项目外壳整体搬入客户端。
|
||||
- 平台主题事实源固定为 `packages/shared/src/theme.css`。画布通用 chrome 固定落在 `@genarrative/image-canvas-react`,主站与 Tauri 必须直接 import 同一组件和作用域样式;客户端不得复制 `src/components/image-editor/`,也不得导入主站完整 `src/index.css`。
|
||||
@@ -102,8 +102,8 @@
|
||||
|
||||
```text
|
||||
resource-overview
|
||||
-> asset-canvas.create(点击“新增资源”)
|
||||
-> asset-canvas.refine(在唯一图片资源上点击“精修资源”)
|
||||
-> asset-canvas.create(当前临时禁用,不向普通用户开放)
|
||||
-> asset-canvas.refine(在唯一图片资源上点击“编辑资源”)
|
||||
-> run(存在 runnableVersion 且 loopback preview 可启动)
|
||||
|
||||
asset-canvas.create|refine
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-10 客户端素材画布临时收敛为现有图片非破坏性编辑
|
||||
|
||||
- 产品决策:资源总览“新增资源”暂时禁用,普通用户只从唯一图片资源进入“编辑资源”。底层 create 草稿和兼容测试继续保留,不删除既有合同,后续恢复入口时不需要重建数据层。
|
||||
- 编辑语义:refine 自动绑定源图片并调用现有 `/api/editor/images/edits`;结果创建独立 asset 和文件,源 asset/文件保持不变,新资源以 `referenceResourceIds` 登记源资源血缘。
|
||||
- 可靠性决策:公开生成状态每次落盘推进草稿 revision,并同步私有账本、进度事件、staging 和 commit;已受理或结果未知的 operation 遇到鉴权失效进入可恢复对账态,刷新登录后继续原 operation,不创建替代任务。
|
||||
- 交互决策:dirty 返回必须通过独立确认面板选择保留或放弃,默认保留并先 flush;图层选择和缩放同时提供指针与键盘路径,禁止嵌套交互元素。
|
||||
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`。
|
||||
|
||||
## 2026-08-06 Game Agent 画布视觉以主站 token 与共享 chrome 为唯一来源
|
||||
|
||||
- 背景:网站与 Tauri 已经共同消费 `image-canvas-core/react` 的 viewport、selection、renderer 和 history,但客户端素材画布仍维护独立的文字工具栏、按钮和状态外观,`1280×800` 下会出现动作逐字换行、主次不清和工作台四区视觉漂移。直接复制主站 `src/components/image-editor` 或整包 `src/index.css` 会重新形成宿主分叉和隐式全局依赖。
|
||||
|
||||
@@ -379,7 +379,7 @@ game-project/
|
||||
2026-07-20 起,产品状态机、P0/P1/P2 范围与后续数据合同以 [`【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md) 为准;本节只保留当前实现边界。
|
||||
|
||||
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
|
||||
- 中间主视窗提供 `resource-overview / asset-canvas / run` 三种状态。资源总览的“新增资源”和图片聚焦的“精修资源”进入 create/refine 素材创作无限画布;素材画布只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 中间主视窗提供 `resource-overview / asset-canvas / run` 三种状态。2026-08-10 起普通用户入口临时收敛为仅编辑现有图片:“新增资源”显示为禁用态且处理函数拒绝 create,图片聚焦的“编辑资源”进入 refine 素材创作无限画布;底层 create 合同仅保留兼容。素材画布只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled` 或 `aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
|
||||
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执和已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频。`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
|
||||
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar;2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
|
||||
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 客户端素材创作无限画布阶段一合同
|
||||
|
||||
更新时间:`2026-08-06`
|
||||
更新时间:`2026-08-10`
|
||||
|
||||
状态:阶段一产品与技术合同已冻结;截至 2026-08-06,客户登录态图片生成、泥点计费链路、中央进度/失败态、Tauri 正式资产提交及画布 UI 对齐阶段三至五已落地,后续增量仍受本文合同约束。
|
||||
状态:阶段一产品与技术合同已冻结;截至 2026-08-10,客户登录态图片生成、泥点计费链路、中央进度/失败态、Tauri 正式资产提交及画布 UI 对齐阶段三至五已落地。当前产品入口按第 16 节临时收敛为仅编辑现有图片,后续增量仍受本文合同约束。
|
||||
|
||||
本文是网站与 AI 游戏创作 Tauri 客户端共享图片画布能力的下一阶段编码依据。若本文与资源管理阶段七的“美术编辑暂缓”口径冲突,以本文对后续素材创作切片的更新决定为准;资源总览既有布局、依赖图和只读聚焦合同继续有效。
|
||||
|
||||
@@ -968,3 +968,18 @@ confirmation-required
|
||||
- 阶段五定向测试覆盖共享 chrome 的 pressed、Tauri 实际消费的工具组/分隔符、按钮名称、expanded/disabled、生成确认、失败重试、保存/取消,以及生成 dialog、进度/失败卡、状态栏、Supervisor composer 和 Agent Dock 的非裁剪/可见性合同。生成请求、登录态、泥点计费、Host Port、草稿 CAS、manifest/revision、Runtime、审批和保存事务均未改变。
|
||||
- 保存设置的产品语义固定为“名称可编辑、用途受控、格式枚举”。create 默认 `game-art`,普通用户只从 `game-art / icon-spec / ui-prototype / art-spritesheet` 四个权威用途选择;界面显示中文名称,不暴露自由 slug 输入。refine 必须继承源 manifest asset 的 `kind` 并锁定,未知历史 kind 原值透传但只显示“原资源用途”,不得借精修改变 subtype。PNG/JPEG/WebP 继续是有限格式选项。
|
||||
- 工具动作与保存设置必须是显式上下两行,保存栅格把主按钮列固定为 `max-content` 且禁止换行;容器宽度不足时保存按钮独占整行。普通用户工作区状态只显示项目名称,不直接展示本机绝对项目路径;显式目录选择、权限确认或开发诊断不受该展示规则替代。
|
||||
|
||||
## 16. 2026-08-10 现有图片非破坏性编辑阶段覆盖条款
|
||||
|
||||
本节是当前产品入口的临时覆盖条款;与第 1、2、4、13 节中要求同时开放 create/refine 入口的文字冲突时,以本节为准。底层 create 草稿、序列化和恢复兼容继续保留,不作为当前普通用户入口。
|
||||
|
||||
- 资源总览的“新增资源”保留为明确禁用态,入口处理函数也必须拒绝 create,不能只依赖按钮外观阻止进入空白画布。
|
||||
- 当前唯一正式入口是图片资源聚焦态的“编辑资源”。打开后必须使用 `intent=refine`,自动加载唯一源图片,并把源资源身份作为图片编辑请求的必选引用。
|
||||
- 普通客户确认编辑后固定调用 `POST /api/editor/images/edits`;提示词、比例、尺寸、资源用途、登录态、泥点计费和原 operation 恢复继续复用现有生成合同。
|
||||
- 编辑结果固定创建新的本地 asset、文件路径、commitId 和资源身份。源 asset 与源文件不得删除、覆盖或复用;新 asset 的 `referenceResourceIds` 必须包含源资源规范身份,资源总览同时保留新旧图片。
|
||||
- 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。
|
||||
- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed。私有账本保留原 operation、原请求字节和原幂等身份,公开状态进入可恢复对账态;登录刷新后只继续同一 operation。
|
||||
- 返回资源总览时,clean 草稿直接保留并退出;dirty 草稿必须使用独立确认面板提供“保留草稿并退出”和“放弃草稿”,默认保留。保留前必须等待当前保存或主动 flush,放弃才允许调用 discard。
|
||||
- 图层选择与图层缩放必须同时提供指针和键盘路径;不得嵌套 button/role=button。Enter/Space 可选择图层,缩放手柄使用原生 button 并提供方向键离散缩放。
|
||||
|
||||
当前阶段不主动扩展 create 专属空画布导入、生成或保存体验;共享持久化、安全、幂等和可访问性缺陷仍必须修复,因为它们直接影响 refine 编辑链路。
|
||||
|
||||
@@ -132,6 +132,7 @@ export type ImageCanvasGenerationProgressPhase =
|
||||
export type ImageCanvasGenerationProgress = {
|
||||
intentId: string;
|
||||
generationId: string;
|
||||
draftRevision?: number;
|
||||
phase: ImageCanvasGenerationProgressPhase;
|
||||
progress: number | null;
|
||||
errorCode: string | null;
|
||||
|
||||
Reference in New Issue
Block a user