修复素材精修提交与事务恢复

统一解析精修素材名并剥离历史提交后缀

将候选提交参数校验前移到零副作用阶段

严格识别被后续提交取代的旧事务并增加并发提交门禁

支持生成占位卡拖动和精修候选唯一提交

补充素材画布回归测试与合同文档
This commit is contained in:
2026-08-22 15:18:16 +08:00
parent c4bf7e02d0
commit 502dc231c1
11 changed files with 1307 additions and 43 deletions
@@ -1310,18 +1310,35 @@ pub(crate) fn create_asset_canvas_draft_at(
.source_asset_id
.as_deref()
.ok_or_else(|| "refine 草稿必须指定 sourceAssetId".to_string())?;
let (asset, _bytes, width, height) =
let (asset, bytes, width, height) =
project_asset_image(root, &manifest, source_asset_id)?;
let resource_id = asset_canvas_source_resource_id(asset);
let media_id = new_asset_canvas_token()?;
let media_type = asset.media_type.clone();
let source_media_ref = AssetCanvasMediaRef::DraftMedia {
media_id: media_id.clone(),
media_type: media_type.clone(),
sha256: asset_canvas_sha256(&bytes),
byte_length: bytes.len() as u64,
pixel_width: width,
pixel_height: height,
};
let media_path = resolve_local_project_path(
root,
&format!(
"{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{}",
input.draft_id,
media_extension(&media_type)?
),
)?;
install_new_asset_canvas_file(&media_path, &bytes, "精修源图片快照")?;
let layer_id = Uuid::new_v4().to_string();
let mut canvas = default_asset_canvas_state();
canvas.layers.push(AssetCanvasLayer {
layer_id: layer_id.clone(),
resource_id: resource_id.clone(),
title: asset.id.clone(),
media_ref: AssetCanvasMediaRef::ProjectAsset {
asset_id: asset.id.clone(),
},
media_ref: source_media_ref,
x: 6000.0 - f64::from(width) / 2.0,
y: 6000.0 - f64::from(height) / 2.0,
width: f64::from(width),
@@ -1356,7 +1373,30 @@ pub(crate) fn create_asset_canvas_draft_at(
created_at: now,
updated_at: now,
};
write_asset_canvas_draft_locked(root, &draft)?;
if let Err(error) = write_asset_canvas_draft_locked(root, &draft) {
if let Some(source_layer) = draft.canvas.layers.first() {
if let AssetCanvasMediaRef::DraftMedia {
media_id,
media_type,
..
} = &source_layer.media_ref
{
if let Ok(extension) = media_extension(media_type) {
let media_path = resolve_local_project_path(
root,
&format!(
"{ASSET_CANVAS_ROOT}/media/{}/{media_id}.{extension}",
draft.draft_id
),
);
if let Ok(media_path) = media_path {
let _ = fs::remove_file(media_path);
}
}
}
}
return Err(error);
}
Ok(CreateAssetCanvasDraftResult {
status: CreateAssetCanvasDraftStatus::Created,
draft,
@@ -1521,9 +1561,27 @@ pub(crate) fn update_asset_canvas_draft_at(
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.status = input.status.clone();
draft.canvas = input.canvas.clone();
// Generation records are advanced by the Rust generation ledger. A regular
// canvas autosave may carry an older frontend snapshot and must never erase
// accepted/running/reconciliation facts written by the backend.
// Generation lifecycle facts remain backend-authoritative, while the card
// placement is ordinary canvas state and may be changed by the user.
// Merge only placeholders so an older autosave cannot erase phase, output,
// error, or identity facts written by the generation ledger.
for incoming in &input.generations {
if let Some(current) = draft
.generations
.iter_mut()
.find(|record| record.generation_id == incoming.generation_id)
{
if current.intent_id != incoming.intent_id {
return Err("generationId 已绑定到不同 intentId".to_string());
}
current.placeholder = incoming.placeholder.clone();
} else {
if draft.generations.len() >= ASSET_CANVAS_MAX_GENERATIONS {
return Err("素材画布生成记录数量已达上限".to_string());
}
draft.generations.push(incoming.clone());
}
}
draft.updated_at = asset_canvas_now();
write_asset_canvas_draft_locked(root, &draft)?;
Ok(UpdateAssetCanvasDraftResult {
@@ -2443,6 +2501,7 @@ enum AssetCanvasLedgerStatus {
Committed,
RolledBack,
ReconciliationRequired,
Superseded,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -2490,6 +2549,7 @@ enum AssetCanvasTransactionStage {
EventAttempted,
RolledBack,
ReconciliationRequired,
Superseded,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -2666,6 +2726,100 @@ fn write_asset_canvas_journal(
)
}
fn read_asset_canvas_transaction_journals(
root: &Path,
) -> Result<Vec<AssetCanvasTransactionJournal>, String> {
let directory = resolve_local_project_path(root, &format!("{ASSET_CANVAS_ROOT}/transactions"))?;
let entries = match fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(_) => return Err("读取素材画布 transaction 目录失败".to_string()),
};
let mut journals = Vec::new();
for entry in entries {
let entry = entry.map_err(|_| "读取素材画布 transaction 失败".to_string())?;
if journals.len() >= ASSET_CANVAS_MAX_DRAFT_SCAN_ENTRIES {
return Err("素材画布 transaction 数量超限".to_string());
}
let metadata = fs::symlink_metadata(entry.path())
.map_err(|_| "读取素材画布 transaction 元数据失败".to_string())?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("素材画布 transaction 必须是普通目录".to_string());
}
let commit_id = entry.file_name().to_string_lossy().into_owned();
validate_uuid_v4(&commit_id, "transaction commitId")?;
let Some(journal) = read_asset_canvas_journal(root, &commit_id)? else {
if read_asset_canvas_ledger(root, &commit_id)?.is_some() {
return Err("素材画布 transaction 缺少 journal".to_string());
}
continue;
};
if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION
|| journal.commit_id != commit_id
{
return Err("素材画布 transaction journal 身份无效".to_string());
}
journals.push(journal);
}
Ok(journals)
}
fn asset_canvas_journal_ledger_identity_matches(
journal: &AssetCanvasTransactionJournal,
ledger: &AssetCanvasCommitLedger,
) -> bool {
ledger.schema_version == ASSET_CANVAS_COMMIT_SCHEMA_VERSION
&& ledger.project_id == journal.project_id
&& ledger.draft_id == journal.draft_id
&& ledger.commit_id == journal.commit_id
&& ledger.idempotency_key == journal.idempotency_key
&& ledger.request_fingerprint == journal.request_fingerprint
&& ledger.expected_project_revision == journal.expected_project_revision
&& ledger.expected_draft_revision == journal.expected_draft_revision
&& ledger.source_layer_id == journal.source_layer_id
&& ledger.media_sha256 == journal.media_sha256
&& ledger.event_id == journal.event_id
}
fn ensure_no_unresolved_asset_canvas_commit_for_asset(
root: &Path,
project_id: &str,
draft_id: &str,
asset_id: &str,
except_commit_id: &str,
) -> Result<(), String> {
for journal in read_asset_canvas_transaction_journals(root)? {
if journal.project_id != project_id
|| journal.draft_id != draft_id
|| journal.asset_id != asset_id
|| journal.commit_id == except_commit_id
{
continue;
}
let ledger = read_asset_canvas_ledger(root, &journal.commit_id)?;
let unresolved = match ledger.as_ref() {
None => true,
Some(ledger) => {
if !asset_canvas_journal_ledger_identity_matches(&journal, ledger) {
return Err("素材画布 journal 与 ledger 身份不一致".to_string());
}
matches!(
ledger.status,
AssetCanvasLedgerStatus::Prepared
| AssetCanvasLedgerStatus::ReconciliationRequired
)
}
};
if unresolved {
return Err(
"该资源存在未收尾的正式图提交,必须先安全恢复或完成对账后才能设为正式图"
.to_string(),
);
}
}
Ok(())
}
fn find_ledger_by_idempotency_key(
root: &Path,
key: &str,
@@ -3199,6 +3353,13 @@ fn commit_asset_canvas_at_internal(
.source_asset_id
.clone()
.unwrap_or_else(|| format!("canvas-{}", input.commit_id));
ensure_no_unresolved_asset_canvas_commit_for_asset(
root,
&manifest.project_id,
&draft.draft_id,
&asset_id,
&input.commit_id,
)?;
if input.intent == AssetCanvasIntent::Create
&& manifest.assets.iter().any(|asset| asset.id == asset_id)
{
@@ -3439,6 +3600,8 @@ pub(crate) fn commit_asset_canvas_candidate_at(
root: &Path,
input: &CommitAssetCanvasCandidateInput,
) -> Result<CommitAssetCanvasExecution, String> {
let _name = validate_asset_canvas_name(&input.name)?;
let _asset_kind = validate_asset_kind(&input.asset_kind)?;
validate_plain_component(&input.source_layer_id, "sourceLayerId", 512)?;
validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?;
validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
@@ -3581,6 +3744,7 @@ pub(crate) enum RecoverAssetCanvasOutcomeStatus {
AlreadyCommitted,
RolledBack,
ReconciliationRequired,
Superseded,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
@@ -3679,6 +3843,134 @@ fn final_image_state_for_journal(
}
}
fn find_superseding_asset_canvas_commit(
root: &Path,
journal: &AssetCanvasTransactionJournal,
current_manifest: &GameCreationAppManifest,
current_manifest_sha256: &str,
current_revision: &AgentRuntimeProjectRevision,
current_revision_sha256: &str,
) -> Result<bool, String> {
if final_image_state_for_journal(root, journal)? != AssetCanvasFinalImageState::Matches {
return Ok(false);
}
for later in read_asset_canvas_transaction_journals(root)? {
if later.commit_id == journal.commit_id
|| later.project_id != journal.project_id
|| later.draft_id != journal.draft_id
|| later.asset_id != journal.asset_id
{
continue;
}
let Some(ledger) = read_asset_canvas_ledger(root, &later.commit_id)? else {
continue;
};
if !asset_canvas_journal_ledger_identity_matches(&later, &ledger)
|| ledger.status != AssetCanvasLedgerStatus::Committed
|| ledger.committed_project_revision != Some(later.target_project_revision)
|| ledger.committed_draft_revision != Some(later.target_draft_revision)
|| ledger.asset_id.as_deref() != Some(later.asset_id.as_str())
|| ledger.event_payload.is_none()
{
continue;
}
if later.expected_project_revision != journal.target_project_revision
|| later.manifest_before_sha256 != journal.manifest_after_sha256
|| later.project_revision_before_sha256.as_deref()
!= Some(journal.project_revision_after_sha256.as_str())
|| later.final_image_relative_path == journal.final_image_relative_path
|| !matches!(
later.stage,
AssetCanvasTransactionStage::Committed
| AssetCanvasTransactionStage::EventAttempted
)
{
continue;
}
let later_manifest_before: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.before.json",
&later.manifest_before_sha256,
)?;
let later_revision_before: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.before.json",
later
.project_revision_before_sha256
.as_deref()
.ok_or_else(|| "后续素材画布事务缺少 revision before 摘要".to_string())?,
)?;
let later_manifest_after: GameCreationAppManifest = read_asset_canvas_snapshot(
root,
&later.commit_id,
"manifest.after.json",
&later.manifest_after_sha256,
)?;
let later_revision_after: AgentRuntimeProjectRevision = read_asset_canvas_snapshot(
root,
&later.commit_id,
"project-revision.after.json",
&later.project_revision_after_sha256,
)?;
let later_manifest_before_sha256 =
asset_canvas_sha256(&asset_canvas_json_bytes(&later_manifest_before)?);
let later_revision_before_sha256 =
asset_canvas_sha256(&asset_canvas_json_bytes(&later_revision_before)?);
if later_manifest_before_sha256 != journal.manifest_after_sha256
|| later_revision_before_sha256 != journal.project_revision_after_sha256
|| current_manifest != &later_manifest_after
|| current_manifest_sha256 != later.manifest_after_sha256
|| current_revision != &later_revision_after
|| current_revision_sha256 != later.project_revision_after_sha256
|| current_revision.revision != later.target_project_revision
|| later_revision_after.revision != later.target_project_revision
|| later.target_project_revision != later.expected_project_revision.saturating_add(1)
|| final_image_state_for_journal(root, &later)? != AssetCanvasFinalImageState::Matches
{
continue;
}
let matching_assets = current_manifest
.assets
.iter()
.filter(|asset| asset.id == later.asset_id)
.collect::<Vec<_>>();
if matching_assets.len() != 1 {
continue;
}
let asset = matching_assets[0];
if asset.local_path != later.final_image_relative_path
|| asset.media_type != later.staged_image.media_type
{
continue;
}
return Ok(true);
}
Ok(false)
}
fn mark_asset_canvas_superseded_locked(
root: &Path,
mut journal: AssetCanvasTransactionJournal,
mut ledger: AssetCanvasCommitLedger,
) -> Result<RecoverAssetCanvasOutcome, String> {
journal.stage = AssetCanvasTransactionStage::Superseded;
journal.updated_at = asset_canvas_now();
ledger.status = AssetCanvasLedgerStatus::Superseded;
ledger.updated_at = asset_canvas_now();
write_asset_canvas_journal(root, &journal)?;
write_asset_canvas_ledger(root, &ledger)?;
Ok(RecoverAssetCanvasOutcome {
commit_id: journal.commit_id,
status: RecoverAssetCanvasOutcomeStatus::Superseded,
event_id: Some(journal.event_id),
asset_id: None,
})
}
fn mark_asset_canvas_reconciliation_locked(
root: &Path,
mut journal: AssetCanvasTransactionJournal,
@@ -3826,6 +4118,33 @@ fn recover_asset_canvas_transaction_locked(
let final_image_state = final_image_state_for_journal(root, &journal)?;
let file_matches = final_image_state == AssetCanvasFinalImageState::Matches;
if ledger.status == AssetCanvasLedgerStatus::Superseded
&& journal.stage == AssetCanvasTransactionStage::Superseded
{
return Ok((
RecoverAssetCanvasOutcome {
commit_id: journal.commit_id,
status: RecoverAssetCanvasOutcomeStatus::Superseded,
event_id: Some(journal.event_id),
asset_id: None,
},
None,
));
}
if ledger.status == AssetCanvasLedgerStatus::RolledBack
&& journal.stage == AssetCanvasTransactionStage::RolledBack
{
return Ok((
RecoverAssetCanvasOutcome {
commit_id: journal.commit_id,
status: RecoverAssetCanvasOutcomeStatus::RolledBack,
event_id: Some(journal.event_id),
asset_id: None,
},
None,
));
}
if ledger.status == AssetCanvasLedgerStatus::Committed {
let asset_present = current_manifest.assets.iter().any(|asset| {
asset.id == journal.asset_id && asset.local_path == journal.final_image_relative_path
@@ -3945,6 +4264,31 @@ fn recover_asset_canvas_transaction_locked(
)
.map(|(outcome, event)| (outcome, Some(event)));
}
let unresolved = matches!(
journal.stage,
AssetCanvasTransactionStage::Prepared
| AssetCanvasTransactionStage::FileInstalled
| AssetCanvasTransactionStage::ManifestInstalled
| AssetCanvasTransactionStage::RevisionInstalled
| AssetCanvasTransactionStage::Verified
| AssetCanvasTransactionStage::ReconciliationRequired
) && matches!(
ledger.status,
AssetCanvasLedgerStatus::Prepared | AssetCanvasLedgerStatus::ReconciliationRequired
);
if unresolved
&& find_superseding_asset_canvas_commit(
root,
&journal,
&current_manifest,
&current_manifest_sha,
&current_revision,
&current_revision_sha,
)?
{
return mark_asset_canvas_superseded_locked(root, journal, ledger)
.map(|outcome| (outcome, None));
}
mark_asset_canvas_reconciliation_locked(root, journal, ledger).map(|outcome| (outcome, None))
}
@@ -1069,6 +1069,10 @@ fn upsert_public_generation_record(
.generations
.iter()
.position(|record| record.generation_id == ledger.generation_id);
let placeholder = index
.and_then(|index| draft.generations.get(index))
.and_then(|record| record.placeholder.clone())
.or_else(|| ledger.placeholder.clone());
let record = AssetCanvasGenerationRecord {
generation_id: ledger.generation_id.clone(),
intent_id: ledger.intent_id.clone(),
@@ -1076,7 +1080,7 @@ fn upsert_public_generation_record(
reference_resource_ids: references,
output_asset_id,
source_layer_id: ledger.source_layer_id.clone(),
placeholder: ledger.placeholder.clone(),
placeholder,
error_code: ledger.error_code.clone(),
created_at: index
.and_then(|index| draft.generations.get(index))
@@ -165,6 +165,57 @@ fn initialize_refine_fixture() -> Fixture {
initialize_refine_fixture_with_resource_id(None)
}
fn force_refine_transaction_unresolved(fixture: &Fixture, commit_id: &str) {
let mut journal = read_asset_canvas_journal(fixture.root(), commit_id)
.expect("read transaction to mark unresolved")
.expect("transaction journal retained");
let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id)
.expect("read transaction ledger")
.expect("transaction ledger retained");
journal.stage = AssetCanvasTransactionStage::ReconciliationRequired;
ledger.status = AssetCanvasLedgerStatus::ReconciliationRequired;
write_asset_canvas_journal(fixture.root(), &journal).expect("mark journal unresolved");
write_asset_canvas_ledger(fixture.root(), &ledger).expect("mark ledger unresolved");
}
fn initialize_refine_fixture_with_later_commit() -> (Fixture, String, String) {
let fixture = initialize_refine_fixture();
let (draft, first_layer_id, first_media_sha256) = add_candidate_layer(&fixture, &fixture.draft);
let staged = stage_image(&fixture, &draft);
let mut first_input = commit_input(
&fixture,
&draft,
&staged,
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
first_input.source_layer_id = Some(first_layer_id.clone());
first_input.media_sha256 = Some(first_media_sha256);
let first_commit_id = first_input.commit_id.clone();
commit_asset_canvas_at(fixture.root(), &first_input).expect("commit first final image");
let draft_after_first =
read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id)
.expect("read draft after first commit")
.expect("draft retained after first commit");
let (draft_before_second, second_layer_id, second_media_sha256) =
add_candidate_layer(&fixture, &draft_after_first);
let staged = stage_image(&fixture, &draft_before_second);
let mut second_input = commit_input(
&fixture,
&draft_before_second,
&staged,
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
second_input.source_layer_id = Some(second_layer_id);
second_input.media_sha256 = Some(second_media_sha256);
let second_commit_id = second_input.commit_id.clone();
commit_asset_canvas_at(fixture.root(), &second_input).expect("commit second final image");
force_refine_transaction_unresolved(&fixture, &first_commit_id);
(fixture, first_commit_id, second_commit_id)
}
fn add_candidate_layer(
fixture: &Fixture,
draft: &AssetCanvasDraft,
@@ -432,6 +483,31 @@ fn refine_preserves_source_and_records_non_destructive_lineage() {
)
.expect("create refine draft")
.draft;
let source_layer = draft.canvas.layers.first().expect("refine source layer");
match &source_layer.media_ref {
AssetCanvasMediaRef::DraftMedia {
media_id,
media_type,
sha256,
..
} => {
let source_path = directory.path().join(format!(
".agent/workbench/asset-canvas/media/{}/{}.{}",
draft.draft_id,
media_id,
media_extension(media_type).expect("source extension")
));
assert_eq!(
fs::read(source_path).expect("read source snapshot"),
source_bytes
);
assert_eq!(sha256, &asset_canvas_sha256(&source_bytes));
}
AssetCanvasMediaRef::ProjectAsset { .. } => {
panic!("refine source must be immutable snapshot")
}
}
assert_eq!(source_layer.resource_id, "local-asset:source-asset");
let fixture = Fixture {
directory,
draft: draft.clone(),
@@ -526,6 +602,40 @@ fn candidate_commit_freezes_layer_and_media_identity_and_keeps_draft_editable()
assert_eq!(last_commit.asset_id, "source-asset");
assert_eq!(last_commit.source_layer_id, candidate_layer_id);
assert_eq!(last_commit.media_sha256, media_sha256);
let source_layer = persisted
.canvas
.layers
.iter()
.find(|layer| layer.resource_id == "local-asset:source-asset")
.expect("immutable refine source layer retained");
let source_media = read_asset_canvas_media_at(
fixture.root(),
&ReadAssetCanvasMediaInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
draft_id: persisted.draft_id.clone(),
media_ref: source_layer.media_ref.clone(),
},
)
.expect("read immutable refine source layer");
assert_eq!(source_media.bytes, png_bytes([200, 20, 40, 255]));
let candidate_layer = persisted
.canvas
.layers
.iter()
.find(|layer| layer.layer_id == candidate_layer_id)
.expect("candidate layer retained");
let candidate_media = read_asset_canvas_media_at(
fixture.root(),
&ReadAssetCanvasMediaInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
draft_id: persisted.draft_id.clone(),
media_ref: candidate_layer.media_ref.clone(),
},
)
.expect("read selected final candidate");
assert_eq!(candidate_media.bytes, fixture.png);
let serialized = fs::read_to_string(
fixture
@@ -581,6 +691,37 @@ fn candidate_commit_preserves_registered_refine_source_identity() {
assert_eq!(asset.id, "source-asset");
}
#[test]
fn invalid_candidate_name_fails_before_staging_side_effects() {
let fixture = initialize_refine_fixture();
let (draft, candidate_layer_id, _) = add_candidate_layer(&fixture, &fixture.draft);
let error = commit_asset_canvas_candidate_at(
fixture.root(),
&CommitAssetCanvasCandidateInput {
project_path: project_path(fixture.root()),
expected_project_id: PROJECT_ID.to_string(),
expected_revision: 0,
expected_draft_revision: draft.revision,
draft_id: draft.draft_id.clone(),
commit_id: Uuid::new_v4().to_string(),
idempotency_key: Uuid::new_v4().to_string(),
source_asset_id: draft.source_asset_id.clone(),
source_layer_id: candidate_layer_id,
name: "".repeat(81),
asset_kind: "illustration".to_string(),
reference_resource_ids: Vec::new(),
},
)
.expect_err("reject invalid candidate name before staging");
assert_eq!(error, "素材名称无效");
let staging = fixture.root().join(".agent/workbench/asset-canvas/staging");
match fs::read_dir(&staging) {
Ok(entries) => assert_eq!(entries.count(), 0),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => panic!("read candidate staging directory failed: {error}"),
}
}
#[test]
fn candidate_commit_recovery_accepts_registered_refine_source_after_revision_install() {
let registered_resource_id = "editor-resource-registered-recovery-source";
@@ -680,6 +821,248 @@ fn candidate_commit_recovery_restores_editing_and_last_commit_identity() {
assert_eq!(last_commit.media_sha256, media_sha256);
}
#[test]
fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit() {
let (fixture, first_commit_id, second_commit_id) =
initialize_refine_fixture_with_later_commit();
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover superseded refine transaction");
let first_outcome = recovered
.result
.outcomes
.iter()
.find(|outcome| outcome.commit_id == first_commit_id)
.expect("first recovery outcome");
let second_outcome = recovered
.result
.outcomes
.iter()
.find(|outcome| outcome.commit_id == second_commit_id)
.expect("second recovery outcome");
assert_eq!(
first_outcome.status,
RecoverAssetCanvasOutcomeStatus::Superseded
);
assert_eq!(
second_outcome.status,
RecoverAssetCanvasOutcomeStatus::AlreadyCommitted
);
let first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id)
.expect("read superseded journal")
.expect("superseded journal retained");
let first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id)
.expect("read superseded ledger")
.expect("superseded ledger retained");
assert_eq!(first_journal.stage, AssetCanvasTransactionStage::Superseded);
assert_eq!(first_ledger.status, AssetCanvasLedgerStatus::Superseded);
assert!(fixture
.root()
.join(&first_journal.final_image_relative_path)
.is_file());
let second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id)
.expect("read current journal")
.expect("current journal retained");
let manifest = current_asset_canvas_manifest(fixture.root()).expect("read current manifest");
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == "source-asset")
.expect("current refine source asset");
assert_eq!(asset.local_path, second_journal.final_image_relative_path);
let persisted =
read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id)
.expect("read current draft")
.expect("current draft retained");
assert_eq!(persisted.status, AssetCanvasDraftStatus::Editing);
assert_eq!(
persisted
.last_commit
.as_ref()
.map(|last| last.commit_id.as_str()),
Some(second_commit_id.as_str())
);
let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("repeat superseded recovery");
assert!(repeated.result.outcomes.iter().any(|outcome| {
outcome.commit_id == first_commit_id
&& outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded
}));
assert!(!repeated
.result
.outcomes
.iter()
.any(|outcome| outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired));
let (draft_before_third, third_layer_id, third_media_sha256) =
add_candidate_layer(&fixture, &persisted);
let staged = stage_image(&fixture, &draft_before_third);
let mut third_input = commit_input(
&fixture,
&draft_before_third,
&staged,
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
third_input.source_layer_id = Some(third_layer_id);
third_input.media_sha256 = Some(third_media_sha256);
commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery");
}
#[test]
fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() {
let (fixture, first_commit_id, second_commit_id) =
initialize_refine_fixture_with_later_commit();
let mut second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id)
.expect("read later journal")
.expect("later journal retained");
second_journal.expected_project_revision += 1;
write_asset_canvas_journal(fixture.root(), &second_journal)
.expect("break later transaction chain");
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover broken later chain");
let first_outcome = recovered
.result
.outcomes
.iter()
.find(|outcome| outcome.commit_id == first_commit_id)
.expect("first recovery outcome");
assert_eq!(
first_outcome.status,
RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
);
let first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id)
.expect("read retained first journal")
.expect("retained first journal");
assert!(fixture
.root()
.join(&first_journal.final_image_relative_path)
.is_file());
}
#[test]
fn recovery_does_not_supersede_when_later_final_image_is_changed() {
let (fixture, _first_commit_id, second_commit_id) =
initialize_refine_fixture_with_later_commit();
let second_journal = read_asset_canvas_journal(fixture.root(), &second_commit_id)
.expect("read later journal")
.expect("later journal retained");
let replacement = png_bytes([240, 100, 30, 255]);
fs::write(
fixture
.root()
.join(&second_journal.final_image_relative_path),
&replacement,
)
.expect("replace later final image");
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover changed later final image");
assert!(recovered.result.outcomes.iter().all(|outcome| {
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
}));
assert_eq!(
fs::read(
fixture
.root()
.join(&second_journal.final_image_relative_path)
)
.expect("retain unknown later final image"),
replacement
);
}
#[test]
fn recovery_keeps_rolled_back_transaction_rolled_back_when_later_commit_exists() {
let (fixture, first_commit_id, _second_commit_id) =
initialize_refine_fixture_with_later_commit();
let mut first_journal = read_asset_canvas_journal(fixture.root(), &first_commit_id)
.expect("read rolled-back journal")
.expect("rolled-back journal retained");
let mut first_ledger = read_asset_canvas_ledger(fixture.root(), &first_commit_id)
.expect("read rolled-back ledger")
.expect("rolled-back ledger retained");
first_journal.stage = AssetCanvasTransactionStage::RolledBack;
first_ledger.status = AssetCanvasLedgerStatus::RolledBack;
write_asset_canvas_journal(fixture.root(), &first_journal)
.expect("persist rolled-back journal");
write_asset_canvas_ledger(fixture.root(), &first_ledger).expect("persist rolled-back ledger");
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover with rolled-back predecessor");
let first_outcome = recovered
.result
.outcomes
.iter()
.find(|outcome| outcome.commit_id == first_commit_id)
.expect("rolled-back recovery outcome");
assert_eq!(
first_outcome.status,
RecoverAssetCanvasOutcomeStatus::RolledBack
);
assert_eq!(first_journal.stage, AssetCanvasTransactionStage::RolledBack);
assert_eq!(first_ledger.status, AssetCanvasLedgerStatus::RolledBack);
}
#[test]
fn unresolved_refine_commit_blocks_and_then_clears_after_recovery() {
let fixture = initialize_refine_fixture();
let (draft, first_layer_id, first_media_sha256) = add_candidate_layer(&fixture, &fixture.draft);
let staged = stage_image(&fixture, &draft);
let mut first_input = commit_input(
&fixture,
&draft,
&staged,
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
first_input.source_layer_id = Some(first_layer_id);
first_input.media_sha256 = Some(first_media_sha256);
commit_asset_canvas_at_internal(
fixture.root(),
&first_input,
Some(AssetCanvasCommitFaultStage::FileInstalled),
)
.expect_err("interrupt first refine commit");
let draft_before_second =
read_asset_canvas_draft_locked(fixture.root(), PROJECT_ID, &fixture.draft.draft_id)
.expect("read draft after interruption")
.expect("draft retained after interruption");
let (draft_before_second, second_layer_id, second_media_sha256) =
add_candidate_layer(&fixture, &draft_before_second);
let staged = stage_image(&fixture, &draft_before_second);
let mut second_input = commit_input(
&fixture,
&draft_before_second,
&staged,
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
second_input.source_layer_id = Some(second_layer_id);
second_input.media_sha256 = Some(second_media_sha256);
let error = commit_asset_canvas_at(fixture.root(), &second_input)
.expect_err("block same-asset commit before recovery");
assert!(error.contains("未收尾的正式图提交"), "{error}");
assert!(
read_asset_canvas_ledger(fixture.root(), &second_input.commit_id)
.expect("check blocked ledger")
.is_none()
);
assert!(!fixture
.root()
.join(".agent/workbench/asset-canvas/transactions")
.join(&second_input.commit_id)
.exists());
recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("recover interrupted first commit");
commit_asset_canvas_at(fixture.root(), &second_input).expect("commit after safe recovery");
}
#[test]
fn discovers_unique_active_refine_draft_and_rejects_ambiguous_candidates() {
let directory = tempfile::tempdir().expect("create refine discovery fixture");
@@ -75,6 +75,7 @@ import {
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { useWalletStore } from '../../stores/useWalletStore';
import { isValidAssetCanvasName } from './assetCanvasNaming';
import type {
LocalAssetCommittedEvent,
TauriImageCanvasHostAdapter,
@@ -151,6 +152,38 @@ type PendingGenerationIdentity = {
const ASSET_CANVAS_WORLD_SIZE = 12_000;
const GENERATION_ASPECT_RATIOS = ['1:1', '2:3', '3:2', '9:16', '16:9'] as const;
type GenerationAspectRatio = (typeof GENERATION_ASPECT_RATIOS)[number];
function numericAspectRatio(aspectRatio: GenerationAspectRatio) {
const [width = 1, height = 1] = aspectRatio.split(':').map(Number);
return width / height;
}
export function generationAspectRatioForOriginalImage(
width: number,
height: number,
): GenerationAspectRatio {
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width <= 0 ||
height <= 0
) {
return '1:1';
}
const originalRatio = width / height;
return GENERATION_ASPECT_RATIOS.reduce<GenerationAspectRatio>(
(closest, candidate) =>
Math.abs(Math.log(originalRatio / numericAspectRatio(candidate))) <
Math.abs(Math.log(originalRatio / numericAspectRatio(closest)))
? candidate
: closest,
'1:1',
);
}
type RuntimeGenerationTask = {
generationId: string;
sourceLayerId: string | null;
@@ -223,7 +256,7 @@ function formatGenerationTaskDuration(
}
function generationPlaceholderSize(
aspectRatio: '1:1' | '2:3' | '3:2' | '9:16' | '16:9',
aspectRatio: GenerationAspectRatio,
imageSize: '0.5K' | '1K' | '2K',
) {
const [ratioWidthText, ratioHeightText] = aspectRatio.split(':');
@@ -347,6 +380,16 @@ type DragState =
historySnapshot: CanvasHistorySnapshot;
changed: boolean;
selectionChanged: false;
}
| {
kind: 'generation';
pointerId: number;
startClientX: number;
startClientY: number;
startTasks: RuntimeGenerationTask[];
taskId: string;
changed: boolean;
selectionChanged: false;
};
function equalSelectionIds(left: string[], right: string[]) {
@@ -450,6 +493,13 @@ export function assetCanvasFailurePresentation(
};
}
if (failure.operation === 'asset-commit') {
if (failure.code === 'asset-name-invalid') {
return {
ariaLabel: '素材名称无效',
kicker: '输入校验',
title: '素材名称需要修改',
};
}
return {
ariaLabel: '素材提交未完成',
kicker: '素材提交',
@@ -664,9 +714,8 @@ export function AssetCanvasSurface({
string | null
>(null);
const [generationPrompt, setGenerationPrompt] = useState('');
const [generationAspectRatio, setGenerationAspectRatio] = useState<
'1:1' | '2:3' | '3:2' | '9:16' | '16:9'
>('1:1');
const [generationAspectRatio, setGenerationAspectRatio] =
useState<GenerationAspectRatio>('1:1');
const [generationImageSize, setGenerationImageSize] = useState<
'0.5K' | '1K' | '2K'
>('1K');
@@ -1421,6 +1470,49 @@ export function AssetCanvasSurface({
(event.clientX - drag.startClientX) / viewportRef.current.scale;
const deltaY =
(event.clientY - drag.startClientY) / viewportRef.current.scale;
if (drag.kind === 'generation') {
const task = drag.startTasks.find(
(item) => item.generationId === drag.taskId,
);
if (!task?.placeholder) return;
const deltaX =
(event.clientX - drag.startClientX) / viewportRef.current.scale;
const deltaY =
(event.clientY - drag.startClientY) / viewportRef.current.scale;
const start = task.placeholder;
const next = {
...start,
x: Math.max(
0,
Math.min(ASSET_CANVAS_WORLD_SIZE - start.width, start.x + deltaX),
),
y: Math.max(
0,
Math.min(ASSET_CANVAS_WORLD_SIZE - start.height, start.y + deltaY),
),
};
drag.changed = deltaX !== 0 || deltaY !== 0;
setGenerationTasks((current) =>
current.map((candidate) =>
candidate.generationId === drag.taskId
? { ...candidate, placeholder: next, updatedAt: Date.now() }
: candidate,
),
);
const currentDraft = draftRef.current;
if (currentDraft) {
draftRef.current = {
...currentDraft,
generations: currentDraft.generations.map((record) =>
record.generationId === drag.taskId
? { ...record, placeholder: next, updatedAt: Date.now() }
: record,
),
};
setDraft(draftRef.current);
}
return;
}
if (drag.kind === 'move') {
if (
(deltaX === 0 && deltaY === 0) ||
@@ -1490,7 +1582,7 @@ export function AssetCanvasSurface({
if (!drag || drag.pointerId !== event.pointerId) return;
suppressNextLayerClickRef.current = drag.kind === 'move' && drag.changed;
dragRef.current = null;
if (drag.changed) {
if (drag.changed && drag.kind !== 'generation') {
captureHistory(drag.historyAction, drag.historySnapshot);
}
if (drag.changed || drag.selectionChanged) {
@@ -1783,6 +1875,10 @@ export function AssetCanvasSurface({
) {
return savePromiseRef.current ?? undefined;
}
if (!isValidAssetCanvasName(assetName)) {
setNotice('素材名称无效,请调整为 1-80 个有效字符');
return undefined;
}
const selectedCandidate =
stableScope.intent === 'refine' && quickEditSourceLayerId
? (layersRef.current.find(
@@ -2109,6 +2205,12 @@ export function AssetCanvasSurface({
setGenerationReferenceResourceIds([...new Set(selectedReferences)]);
generationDialogQuickEditRef.current = quickEdit;
if (quickEdit && sourceLayer) {
setGenerationAspectRatio(
generationAspectRatioForOriginalImage(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
),
);
setQuickEditSourceLayerId(sourceLayer.id);
setQuickEditOpen(true);
setGenerationDialog(null);
@@ -2231,6 +2333,30 @@ export function AssetCanvasSurface({
createdAt: Date.now(),
updatedAt: Date.now(),
};
const initialGenerationRecord: ImageCanvasGenerationRecord = {
generationId: identity.generationId,
intentId: identity.intentId,
phase: 'generation-running',
referenceResourceIds: [...generationReferenceResourceIds],
outputAssetId: null,
sourceLayerId: sourceLayer?.id ?? null,
placeholder,
errorCode: null,
createdAt: initialTask.createdAt,
updatedAt: initialTask.updatedAt,
};
const draftWithGeneration = draftRef.current;
if (draftWithGeneration) {
const nextDraft = {
...draftWithGeneration,
generations: [
...draftWithGeneration.generations,
initialGenerationRecord,
],
};
draftRef.current = nextDraft;
setDraft(nextDraft);
}
setGenerationTasksCollapsed(false);
setGenerationTaskTab('active');
setGenerationTasks((current) => [...current, initialTask]);
@@ -2243,8 +2369,7 @@ export function AssetCanvasSurface({
const frozenImageSize = generationImageSize;
const frozenAssetKind = assetKind;
const frozenAssetName = assetName;
const needsDraftPersist =
documentVersionRef.current !== persistedDocumentVersionRef.current;
const needsDraftPersist = true;
setGenerationDialog(null);
setQuickEditOpen(false);
dragRef.current = null;
@@ -2484,6 +2609,22 @@ export function AssetCanvasSurface({
setLifecycle({ kind: 'canvas.editing', dirty: true });
}, []);
const continueAfterAssetValidationFailure = useCallback(() => {
if (
lifecycleRef.current.kind !== 'canvas.failed' ||
lifecycleRef.current.operation !== 'asset-commit' ||
lifecycleRef.current.code !== 'asset-name-invalid'
) {
return;
}
pendingCommitRef.current = null;
setNotice('素材名称无效,请调整为 1-80 个有效字符');
setLifecycle({
kind: 'canvas.editing',
dirty: documentVersionRef.current !== persistedDocumentVersionRef.current,
});
}, []);
const continueAfterCancellationFailure = useCallback(() => {
if (
lifecycleRef.current.kind !== 'canvas.failed' ||
@@ -2553,8 +2694,8 @@ export function AssetCanvasSurface({
: null) ??
layers.find(
(layer) =>
layer.mediaRef.kind === 'project-asset' &&
layer.mediaRef.assetId === stableScope.sourceAssetId,
stableScope.intent === 'refine' &&
layer.resourceId === draft?.sourceResourceId,
) ??
null;
@@ -3146,8 +3287,29 @@ export function AssetCanvasSurface({
: '重试保存'}
</CanvasChromeButton>
) : null}
{lifecycle.operation === 'asset-commit' ||
lifecycle.operation === 'recovery' ? (
{lifecycle.operation === 'asset-commit' &&
lifecycle.code === 'asset-name-invalid' ? (
<CanvasChromeButton
label="继续编辑"
icon={<Undo2 size={15} aria-hidden="true" />}
className="asset-canvas-surface__primary-action"
onClick={continueAfterAssetValidationFailure}
>
</CanvasChromeButton>
) : null}
{lifecycle.operation === 'asset-commit' &&
lifecycle.code !== 'asset-name-invalid' ? (
<CanvasChromeButton
label="重新加载并安全恢复"
icon={<RotateCcw size={15} aria-hidden="true" />}
className="asset-canvas-surface__primary-action"
onClick={retryCanvasRecovery}
>
</CanvasChromeButton>
) : null}
{lifecycle.operation === 'recovery' ? (
<CanvasChromeButton
label="重新加载并安全恢复"
icon={<RotateCcw size={15} aria-hidden="true" />}
@@ -3363,6 +3525,26 @@ export function AssetCanvasSurface({
}
role="status"
aria-label={`生成任务:${generationPhaseLabels[task.phase]}`}
onPointerDown={(event) => {
if (backgroundInteractionLockedRef.current) return;
if ((event.target as HTMLElement).closest('button')) return;
event.stopPropagation();
dragRef.current = {
kind: 'generation',
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startTasks: generationTasks.map((candidate) => ({
...candidate,
placeholder: candidate.placeholder
? { ...candidate.placeholder }
: null,
})),
taskId: task.generationId,
changed: false,
selectionChanged: false,
};
}}
>
<span aria-hidden="true" />
<strong>{generationPhaseLabels[task.phase]}</strong>
@@ -0,0 +1,82 @@
const COMMIT_ID_SUFFIX =
/--[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const WINDOWS_RESERVED_NAMES = new Set([
'CON',
'PRN',
'AUX',
'NUL',
'COM1',
'COM2',
'COM3',
'COM4',
'COM5',
'COM6',
'COM7',
'COM8',
'COM9',
'LPT1',
'LPT2',
'LPT3',
'LPT4',
'LPT5',
'LPT6',
'LPT7',
'LPT8',
'LPT9',
]);
const FORBIDDEN_FILE_NAME_CHARACTERS = '/\\:*?"<>|';
function isForbiddenFileNameCharacter(value: string) {
const code = value.codePointAt(0) ?? 0;
return (
code <= 0x1f ||
code === 0x7f ||
FORBIDDEN_FILE_NAME_CHARACTERS.includes(value)
);
}
function sanitizeAssetBaseName(value: string, fallback: string) {
const normalized = value
.normalize('NFC')
.split('')
.filter((character) => !isForbiddenFileNameCharacter(character))
.join('');
const bounded = Array.from(normalized)
.slice(0, 80)
.join('')
.replace(/[. ]+$/u, '');
if (
bounded.length === 0 ||
WINDOWS_RESERVED_NAMES.has(bounded.split('.')[0]?.toUpperCase() ?? '')
) {
return fallback;
}
return bounded;
}
export function canonicalAssetBaseName(
localPath: string,
fallback = '画布素材',
) {
const fileName = localPath.split(/[\\/]/u).filter(Boolean).pop() ?? '';
let stem = fileName.replace(/\.[^.]+$/u, '');
let next = stem.replace(COMMIT_ID_SUFFIX, '');
while (next !== stem) {
stem = next;
next = stem.replace(COMMIT_ID_SUFFIX, '');
}
return sanitizeAssetBaseName(next, fallback);
}
export function isValidAssetCanvasName(name: string) {
if (
name.length === 0 ||
Array.from(name).length > 80 ||
Array.from(name).some(isForbiddenFileNameCharacter) ||
/[. ]$/u.test(name) ||
WINDOWS_RESERVED_NAMES.has(name.split('.')[0]?.toUpperCase() ?? '')
) {
return false;
}
return true;
}
@@ -358,6 +358,13 @@
);
color: var(--platform-text-strong);
box-shadow: var(--platform-nav-active-shadow);
cursor: grab;
touch-action: none;
user-select: none;
}
.asset-canvas-surface__generation-placeholder:active {
cursor: grabbing;
}
.asset-canvas-surface__generation-placeholder > span {
@@ -182,20 +182,22 @@ function failure(error: unknown): ImageCanvasHostResult<never> {
const normalized = message.toLowerCase();
return {
status: 'failed',
code: message.includes('reconciliation-required')
? 'reconciliation-required'
: message.includes('project-identity-conflict')
? 'project-identity-conflict'
: normalized.includes('external editor api')
? 'platform-service-configuration'
: normalized.includes('authentication-required') ||
message.includes('登录已失效')
? 'authentication-required'
: message.includes('泥点余额不足')
? 'insufficient-mud-points'
: message.includes('平台图片生成服务暂不可用')
? 'platform-service-configuration'
: 'tauri-command-failed',
code: message.includes('素材名称无效')
? 'asset-name-invalid'
: message.includes('reconciliation-required')
? 'reconciliation-required'
: message.includes('project-identity-conflict')
? 'project-identity-conflict'
: normalized.includes('external editor api')
? 'platform-service-configuration'
: normalized.includes('authentication-required') ||
message.includes('登录已失效')
? 'authentication-required'
: message.includes('泥点余额不足')
? 'insufficient-mud-points'
: message.includes('平台图片生成服务暂不可用')
? 'platform-service-configuration'
: 'tauri-command-failed',
message,
};
}
@@ -51,6 +51,7 @@ import type {
GameIterationVersion,
ProjectResourceCanvasLayoutMode,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { canonicalAssetBaseName } from '../../features/asset-canvas/assetCanvasNaming';
import {
type AssetCanvasCommitNotification,
type AssetCanvasExitResult,
@@ -374,11 +375,6 @@ const approvalOptions: Array<{
},
];
function assetNameFromLocalPath(localPath: string) {
const fileName = localPath.split(/[\\/]/u).filter(Boolean).pop() ?? '';
return fileName.replace(/\.[^.]+$/u, '').trim() || '';
}
function formatMediaDuration(duration: number | null) {
if (duration === null || !Number.isFinite(duration) || duration < 0) {
return '载入后显示';
@@ -2377,7 +2373,7 @@ export default function ProjectDevelopmentView({
flowId,
sessionId,
expectedHostRevision: String(expectedProjectRevision),
initialAssetName: assetNameFromLocalPath(sourceAsset.localPath),
initialAssetName: canonicalAssetBaseName(sourceAsset.localPath),
initialAssetKind: sourceAsset.kind,
scope,
host: createTauriImageCanvasHostAdapter({
@@ -24,8 +24,13 @@ import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
canonicalAssetBaseName,
isValidAssetCanvasName,
} from '../src/features/asset-canvas/assetCanvasNaming';
import {
AssetCanvasSurface,
generationAspectRatioForOriginalImage,
type RenderAssetCanvasImage,
shouldApplyAssetCanvasDraftCandidate,
} from '../src/features/asset-canvas/AssetCanvasSurface';
@@ -514,6 +519,10 @@ function memoryHost(input?: {
primarySelectedLayerId: candidateLayer.layerId,
},
};
const existingGeneration = draft.generations.find(
(generation) =>
generation.generationId === generationInput.generationId,
);
const generation = {
generationId: generationInput.generationId,
intentId: generationInput.intentId,
@@ -521,12 +530,21 @@ function memoryHost(input?: {
referenceResourceIds: generationInput.referenceResourceIds,
outputAssetId: null,
sourceLayerId: generationInput.sourceLayerId,
placeholder: generationInput.placeholder,
placeholder:
existingGeneration?.placeholder ?? generationInput.placeholder,
errorCode: null,
createdAt: 1,
createdAt: existingGeneration?.createdAt ?? 1,
updatedAt: 2,
};
draft = { ...draft, generations: [...draft.generations, generation] };
draft = {
...draft,
generations: [
...draft.generations.filter(
(record) => record.generationId !== generationInput.generationId,
),
generation,
],
};
generationInput.onProgress?.({
intentId: generationInput.intentId,
generationId: generationInput.generationId,
@@ -790,6 +808,32 @@ afterEach(() => {
});
describe('Tauri 素材创作无限画布独立 Surface', () => {
it('从精修文件名剥离历史提交后缀并保持后端合法名称', () => {
expect(
canonicalAssetBaseName(
'assets/canvas/direct-game-background--fb66de66-e50c-4c13-8fcc-8ed7b5b49aff--6faacb1d-8f56-4186-ad51-fa70988d4879.png',
),
).toBe('direct-game-background');
expect(canonicalAssetBaseName('assets/art.png ')).toBe('art');
expect(canonicalAssetBaseName('assets/CON.png')).toBe('画布素材');
expect(
canonicalAssetBaseName(
`assets/${'很'.repeat(90)}--fb66de66-e50c-4c13-8fcc-8ed7b5b49aff.png`,
),
).toHaveLength(80);
expect(isValidAssetCanvasName('direct-game-background')).toBe(true);
expect(isValidAssetCanvasName('name ')).toBe(false);
});
it('修改图片时将原图比例映射为最接近的受支持比例,异常尺寸回退 1:1', () => {
expect(generationAspectRatioForOriginalImage(1600, 900)).toBe('16:9');
expect(generationAspectRatioForOriginalImage(900, 1600)).toBe('9:16');
expect(generationAspectRatioForOriginalImage(1200, 800)).toBe('3:2');
expect(generationAspectRatioForOriginalImage(800, 1200)).toBe('2:3');
expect(generationAspectRatioForOriginalImage(0, 1200)).toBe('1:1');
expect(generationAspectRatioForOriginalImage(Number.NaN, 1200)).toBe('1:1');
});
it('直接消费共享 chrome,并暴露工具栏结构与原生按钮状态', async () => {
const memory = memoryHost();
renderSurface(memory.host);
@@ -832,6 +876,82 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy();
});
it('精修提交候选后定位当前最终图只跟随 lastCommit 的候选图层', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const sourceLayer = {
...keyboardCanvas().layers[0],
layerId: 'source-layer',
resourceId: 'local-asset:source-asset',
title: '入口原图快照',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'source-snapshot',
mediaType: 'image/png' as const,
sha256: 'a'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
x: 0,
y: 0,
};
const candidateLayer = {
...keyboardCanvas().layers[1],
layerId: 'candidate-layer',
resourceId: 'draft-media:candidate',
title: '已提交候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'candidate',
mediaType: 'image/png' as const,
sha256: 'b'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
x: 3000,
y: 2800,
};
const initialDraft = draftFixture(refineScope, {
...emptyCanvas(),
layers: [sourceLayer, candidateLayer],
});
initialDraft.lastCommit = {
commitId: 'commit-candidate',
idempotencyKey: 'idempotency-candidate',
assetId: 'source-asset',
eventId: 'event-candidate',
committedProjectRevision: 1,
sourceLayerId: candidateLayer.layerId,
mediaSha256: candidateLayer.mediaRef.sha256,
};
const memory = memoryHost({ initialDraft });
renderSurface(memory.host, refineScope);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: '定位当前最终图' }));
expect(
screen
.getByRole('button', { name: '选择图层 已提交候选图' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen
.getByRole('button', { name: '选择图层 入口原图快照' })
.getAttribute('aria-pressed'),
).toBe('false');
expect(
document.querySelector<HTMLElement>('.genarrative-image-canvas__world')
?.style.transform,
).toBe('translate(-2570px, -2495px) scale(1)');
expect(screen.getByText('已定位当前最终图')).toBeTruthy();
});
it('精修顶栏只保留画布级动作,图片点击直接打开快速编辑', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
@@ -982,6 +1102,9 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
name: '快速编辑图片',
});
expect(within(quickEdit).getByLabelText('图片提示词')).not.toBeNull();
expect(
(within(quickEdit).getByLabelText('图片比例') as HTMLSelectElement).value,
).toBe('3:2');
expect(within(quickEdit).getByText('参考资源:源资源')).toBeTruthy();
expect(screen.queryByRole('dialog', { name: 'AI 图片生成' })).toBeNull();
@@ -1356,6 +1479,54 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expectLockedBackgroundCannotCallPaidPorts(memory);
});
it('素材名称校验失败不进入安全恢复流程', async () => {
const refineScope: ImageCanvasHostScope = {
...scope,
intent: 'refine',
sourceAssetId: 'source-asset',
};
const candidateLayer = {
...keyboardCanvas().layers[0],
layerId: 'candidate-layer',
resourceId: 'draft-media:candidate',
title: '候选图',
mediaRef: {
kind: 'draft-media' as const,
mediaId: 'candidate',
mediaType: 'image/png' as const,
sha256: 'c'.repeat(64),
byteLength: 4,
pixelWidth: 40,
pixelHeight: 30,
},
};
const memory = memoryHost({
initialDraft: draftFixture(refineScope, {
...emptyCanvas(),
layers: [candidateLayer],
}),
commitFailure: {
code: 'asset-name-invalid',
message: '素材名称无效',
},
});
renderSurface(memory.host, refineScope);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: '选择图层 候选图' }));
fireEvent.click(screen.getByRole('button', { name: '设为最终图' }));
const failure = await screen.findByRole('alert', {
name: '素材名称无效',
});
expect(failure.textContent).toContain('素材名称无效');
expect(
screen.queryByRole('button', { name: '重新加载并安全恢复' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
await screen.findByText('画布可编辑');
expect(screen.getByText(/素材名称无效/)).toBeTruthy();
});
it('恢复与取消故障使用各自的可访问名称和允许动作', async () => {
const recoveryMemory = memoryHost({
initialDraft: draftFixture(scope),
@@ -2347,6 +2518,83 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(memory.selectedCandidateCommits).toHaveLength(0);
expect(onCommitted).not.toHaveBeenCalled();
});
it('生成中的进度卡片可拖动,并将位置自动保存到草稿', async () => {
const gate = deferred<void>();
const memory = memoryHost({
initialDraft: draftFixture(scope, keyboardCanvas()),
generationGate: gate,
});
renderSurface(memory.host);
const first = await screen.findByRole('button', {
name: '选择图层 第一层',
});
fireEvent.click(first);
const quickEdit = await screen.findByRole('region', {
name: '快速编辑图片',
});
fireEvent.change(within(quickEdit).getByLabelText('图片提示词'), {
target: { value: '拖动中的候选图片' },
});
fireEvent.click(within(quickEdit).getByRole('button', { name: '修改' }));
await waitFor(() => expect(memory.generationCalls).toHaveLength(1));
const placeholder = await screen.findByRole('status', {
name: '生成任务:正在生成图片',
});
const initialPlaceholder = memory.getDraft()?.generations[0]?.placeholder;
expect(initialPlaceholder).not.toBeNull();
const initialUpdateCount = memory.updateDraft.mock.calls.length;
fireEvent.pointerDown(placeholder, {
pointerId: 31,
clientX: 100,
clientY: 120,
});
fireEvent.pointerMove(window, {
pointerId: 31,
clientX: 160,
clientY: 160,
});
fireEvent.pointerUp(window, {
pointerId: 31,
clientX: 160,
clientY: 160,
});
await waitFor(() =>
expect(memory.updateDraft.mock.calls.length).toBeGreaterThan(
initialUpdateCount,
),
);
const movedPlaceholder = memory.getDraft()?.generations[0]?.placeholder;
expect(movedPlaceholder).toEqual(
expect.objectContaining({
x: initialPlaceholder!.x + 120,
y: initialPlaceholder!.y + 80,
}),
);
const generationId = memory.generationCalls[0]!.generationId;
await act(async () => gate.resolve());
await waitFor(() =>
expect(
memory
.getDraft()
?.generations.find(
(generation) => generation.generationId === generationId,
)?.phase,
).toBe('candidate-ready'),
);
expect(
memory
.getDraft()
?.generations.find(
(generation) => generation.generationId === generationId,
)?.placeholder,
).toEqual(movedPlaceholder);
});
it('生成期间立即显示占位和任务进度,且画布保持可操作', async () => {
const gate = deferred<void>();
const memory = memoryHost({
@@ -4808,3 +4808,10 @@
- 只依赖共享画布的全局 `MIN_SCALE/MAX_SCALE`,或只在“复位”时 fit,不会约束滚轮和平移拖拽;用户仍能把整个资源世界移到视口外。每一个 viewport 写入口都必须经过同一个边界函数,容器尺寸和资源 extent 改变后也要重新约束。
- 隐藏边界不能从搜索后的 `visibleResources` 派生,否则输入搜索词会突然收缩边界并跳动 viewport。应使用全项目布局 positions、真实卡片尺寸和分区世界 extent,搜索只控制渲染与连线可见性。
## 素材画布旧提交不能只按当前状态猜测恢复(2026-08-22)
- 现象:同一 refine 资源的旧事务停在 `reconciliation-required`,后续另一笔事务已成功并更新当前 manifest/revision。恢复器只对比旧事务自己的 before/after 快照时,会把“已被后续提交取代”永久误报为需要人工对账。
- 处理:只有后继事务 committed、项目/草稿/资源身份一致、旧 after 与后继 before 精确衔接、后继 after 与当前 manifest/revision 精确一致、两份候选文件与 manifest 唯一引用都验证通过时,才把旧事务标为 `superseded`;候选文件和审计记录都保留。任何证据不完整继续失败关闭。
- 门禁:同一资源存在 prepared 或 reconciliation 事务时,新的“设为正式图”提交必须先被拒绝并引导安全恢复,不能继续制造另一笔可能覆盖旧结果的提交。`committed/rolled-back/superseded` 是可继续后续提交的终态。
- 关联坑:正式文件路径会追加 commitId,重新打开 refine 时不能把整个文件 stem 当作下一次素材名,否则多次精修后超过 80 字符并阻塞提交。路径只推导显示名,且必须循环剥离历史 `--<uuid>` 后缀;确定性名称/用途校验要发生在读取候选与 staging 前,并按输入错误处理而不是进入事务恢复。
@@ -903,6 +903,13 @@ cancelling
| A27 | 取消 | clean、dirty、generating、staging、committing | 分别符合第 12 节;committing 不伪装成可取消 |
| A28 | 恢复草稿 | 主文件损坏但恢复副本可信/不可信 | 可信副本恢复到 clean history 基线;不可信进入对账,不猜测 |
| A29 | 登录刷新重放 | context、参考图准备或首次提交返回 401,刷新后以相同 generationId 和幂等身份重放 | 401 账本保持可恢复且第二次真实访问平台;403 直接失败且不刷新;远端最多受理一次 |
| A30 | 生成卡片拖动 | 生成中拖动占位卡片 | 卡片位置按画布坐标更新并持久化到 generation record,任务状态刷新不覆盖用户位置 |
| A31 | 精修最终图唯一性 | 选择另一候选图设为最终图 | 入口原图和所有其他候选保持各自快照,只有 `lastCommit.sourceLayerId` 标识唯一正式候选,更新正式 asset 不反向改写历史图层 |
| A32 | 精修默认比例 | 打开图片的快速编辑 | 按原图宽高映射到最接近的支持比例(1:1、2:3、3:2、9:16、16:9);尺寸无效时回退 1:1 |
| A33 | 旧提交被后继提交取代 | 同一 asset 的旧事务未收尾,且后继 committed 事务链、当前 manifest/revision 与最终文件全部可证明 | 旧事务进入 `superseded`,不回滚、不覆盖当前正式图、不重放旧事件;证据不完整仍进入对账 |
| A34 | 同资源并发正式提交 | 同一 project/draft/asset 存在 prepared 或 reconciliation 事务时再次提交 | 拒绝新提交并要求先安全恢复;已 committed/rolled-back/superseded 事务不阻塞后续提交 |
| A35 | 精修文件名包含历史提交后缀 | 后续精修重新打开当前 `localPath`,或再次生成 / 设为最终图 | 统一剥离文件名末尾一个或多个 `--<uuid>` 后缀并规范化为合法 1..=80 字符显示名;生成与最终提交使用同一结果 |
| A36 | 确定性提交参数无效 | 候选提交名称或用途在校验阶段失败 | 在读取候选、staging、transaction 或 ledger 写入前零副作用失败;UI 作为输入校验错误允许继续编辑,不触发安全恢复 |
阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。
@@ -973,6 +980,7 @@ confirmation-required
- 桌面 `>760px` 统一使用 `100dvh` 的两行工作台网格,第一行是内部滚动的主视窗/Supervisor,第二行是常驻 Dock。正式 `1280×800` 基线下页面级尺寸合同为 window/document/body `1280×800`;无登录会话的真实 Vite 入口按安全合同停在登录页,不绕过门禁,工作台内部由 AppSurface、共享 chrome 和 CSS 合同测试提供可重复证据。
- 阶段五定向测试覆盖共享 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 继续是有限格式选项。
- refine 的继承名称从当前源 `localPath` 派生时必须使用唯一 canonical 规则:取文件名、去扩展名、循环剥离末尾 `--<uuid v4>` 历史提交后缀,再执行 NFC、非法字符清理、1..=80 字符边界、Windows 保留名和尾随空格/点防护。生成请求和“设为最终图”不得各自解析。名称 / 用途校验失败属于确定性的输入错误,后端必须发生在读取候选与 staging 之前,前端不得把它呈现为事务恢复。
- 工具动作与保存设置必须是显式上下两行,保存栅格把主按钮列固定为 `max-content` 且禁止换行;容器宽度不足时保存按钮独占整行。普通用户工作区状态只显示项目名称,不直接展示本机绝对项目路径;显式目录选择、权限确认或开发诊断不受该展示规则替代。
## 16. 2026-08-10 全类型现有资源非破坏性编辑阶段覆盖条款
@@ -1003,5 +1011,6 @@ confirmation-required
- Tauri 画布普通滚轮按二维 delta 平移,Shift 加纵向滚轮作为横向平移回退,Ctrl/Command 加滚轮才围绕指针缩放;一次 Shift 指针事件序列只能切换一次选择。生成中必须把焦点移到“停止等待”,并阻止 wheel、指针拖拽、缩放快捷键和方向键继续改变画布,避免生成恢复期间写入新的 UI 快照。
- `editing` 是唯一允许发起新生成的生命周期。`generating/committing/recovering/failed` 及独立 modal 打开时,画布与顶部工具栏必须同时 disabled/inert;生成事件 handler 还要读取当前权威 lifecycle 再次失败关闭,不能依赖遮罩层或渲染时闭包。
- asset transaction journal 已精确证明目标 asset、正式媒体和 target revision 写入时,恢复应允许当前 manifest 包含该目标的同时已有后续合法追加,且只要求 `currentRevision >= targetRevision`;补齐同一 ledger/draft 后仍复用原 commit/event 身份,不能因为整个 manifest 不再等于历史 after 快照而永久对账。
- 未收尾事务只有在同一 project/draft/asset 的后继事务为 `committed/event-attempted`,且旧事务 after 与后继 before、后继 after 与当前 manifest/revision、两份最终文件摘要和 manifest 唯一引用全部严格匹配时,才可标记为 `superseded`。该终态保留旧 journal、ledger、snapshots 与候选文件,不修改草稿 lastCommit,也不重放旧事件;任何证据缺失继续保持 `reconciliation-required`。正式提交入口发现同资源存在 `prepared/reconciliation-required` 事务时必须先拒绝并引导安全恢复,避免制造第二笔可覆盖旧结果的提交。
当前阶段不主动扩展 create 专属空画布导入、生成或保存体验;图片继续复用现有画布,其他类型使用同一资源编辑壳按能力分流,不能把视频、音频或版本强塞进图片图层模型。共享持久化、安全、幂等和可访问性缺陷仍必须修复,因为它们直接影响编辑链路。