修复素材精修提交与事务恢复
统一解析精修素材名并剥离历史提交后缀 将候选提交参数校验前移到零副作用阶段 严格识别被后续提交取代的旧事务并增加并发提交门禁 支持生成占位卡拖动和精修候选唯一提交 补充素材画布回归测试与合同文档
This commit is contained in:
@@ -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,
|
||||
¤t_manifest,
|
||||
¤t_manifest_sha,
|
||||
¤t_revision,
|
||||
¤t_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 {
|
||||
|
||||
+16
-14
@@ -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 专属空画布导入、生成或保存体验;图片继续复用现有画布,其他类型使用同一资源编辑壳按能力分流,不能把视频、音频或版本强塞进图片图层模型。共享持久化、安全、幂等和可访问性缺陷仍必须修复,因为它们直接影响编辑链路。
|
||||
|
||||
Reference in New Issue
Block a user