|
|
|
@@ -358,6 +358,8 @@ struct AssetCanvasGenerationLedger {
|
|
|
|
|
candidate_media_ref: Option<AssetCanvasMediaRef>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
candidate_layer_id: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
candidate_layer_acknowledged_revision: Option<u64>,
|
|
|
|
|
commit_result: Option<PrivateCommitResult>,
|
|
|
|
|
error_code: Option<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
@@ -1397,6 +1399,7 @@ fn persist_generation_candidate_at(
|
|
|
|
|
ledger.current_draft_revision = Some(draft.revision);
|
|
|
|
|
ledger.candidate_media_ref = Some(media_ref.clone());
|
|
|
|
|
ledger.candidate_layer_id = Some(layer_id);
|
|
|
|
|
ledger.candidate_layer_acknowledged_revision = None;
|
|
|
|
|
write_generation_ledger(root, ledger)?;
|
|
|
|
|
Ok((
|
|
|
|
|
record,
|
|
|
|
@@ -1408,6 +1411,171 @@ fn persist_generation_candidate_at(
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn protect_candidate_layers_before_frontend_update_at(
|
|
|
|
|
root: &Path,
|
|
|
|
|
current_draft: &AssetCanvasDraft,
|
|
|
|
|
next_revision: u64,
|
|
|
|
|
incoming_canvas: &AssetCanvasState,
|
|
|
|
|
) -> Result<AssetCanvasState, String> {
|
|
|
|
|
let mut merged_canvas = incoming_canvas.clone();
|
|
|
|
|
for record in ¤t_draft.generations {
|
|
|
|
|
if record.phase != AssetCanvasGenerationStatus::CandidateReady {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if ledger.project_id != current_draft.project_id
|
|
|
|
|
|| ledger.draft_id != current_draft.draft_id
|
|
|
|
|
|| ledger.generation_id != record.generation_id
|
|
|
|
|
|| ledger.candidate_layer_acknowledged_revision.is_some()
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some(layer_id) = ledger.candidate_layer_id.clone() else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let Some(candidate_layer) = current_draft
|
|
|
|
|
.canvas
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|layer| layer.layer_id == layer_id)
|
|
|
|
|
else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if merged_canvas
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|layer| layer.layer_id == layer_id)
|
|
|
|
|
{
|
|
|
|
|
ledger.candidate_layer_acknowledged_revision = Some(next_revision);
|
|
|
|
|
write_generation_ledger(root, &mut ledger)?;
|
|
|
|
|
} else {
|
|
|
|
|
merged_canvas.layers.push(candidate_layer.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(merged_canvas)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) fn restore_unacknowledged_candidate_layers_at(
|
|
|
|
|
root: &Path,
|
|
|
|
|
expected_project_id: &str,
|
|
|
|
|
draft_id: &str,
|
|
|
|
|
) -> Result<Option<AssetCanvasDraft>, String> {
|
|
|
|
|
validate_asset_canvas_project_identity(root, expected_project_id)?;
|
|
|
|
|
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
|
|
|
|
|
let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?;
|
|
|
|
|
let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)?
|
|
|
|
|
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
|
|
|
|
|
if draft.project_id != manifest.project_id || draft.draft_id != draft_id {
|
|
|
|
|
return Err("素材画布候选恢复身份不一致".to_string());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut changed = false;
|
|
|
|
|
for record in &draft.generations {
|
|
|
|
|
if record.phase != AssetCanvasGenerationStatus::CandidateReady {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if ledger.project_id != draft.project_id
|
|
|
|
|
|| ledger.draft_id != draft.draft_id
|
|
|
|
|
|| ledger.generation_id != record.generation_id
|
|
|
|
|
|| ledger.candidate_layer_acknowledged_revision.is_some()
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let Some(layer_id) = ledger.candidate_layer_id.as_deref() else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
if draft
|
|
|
|
|
.canvas
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|layer| layer.layer_id == layer_id)
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let AssetCanvasMediaRef::DraftMedia {
|
|
|
|
|
media_id,
|
|
|
|
|
pixel_width,
|
|
|
|
|
pixel_height,
|
|
|
|
|
..
|
|
|
|
|
} = ledger
|
|
|
|
|
.candidate_media_ref
|
|
|
|
|
.as_ref()
|
|
|
|
|
.ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())?
|
|
|
|
|
else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let (x, y, width, height) = ledger
|
|
|
|
|
.placeholder
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|placeholder| {
|
|
|
|
|
(
|
|
|
|
|
placeholder.x,
|
|
|
|
|
placeholder.y,
|
|
|
|
|
placeholder.width,
|
|
|
|
|
placeholder.height,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_else(|| {
|
|
|
|
|
(
|
|
|
|
|
6000.0 - f64::from(*pixel_width) / 2.0,
|
|
|
|
|
6000.0 - f64::from(*pixel_height) / 2.0,
|
|
|
|
|
f64::from(*pixel_width),
|
|
|
|
|
f64::from(*pixel_height),
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
let z_index = draft
|
|
|
|
|
.canvas
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|layer| layer.z_index)
|
|
|
|
|
.max()
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
.checked_add(1)
|
|
|
|
|
.ok_or_else(|| "生成候选图层层序已达到上限".to_string())?;
|
|
|
|
|
draft.canvas.layers.push(AssetCanvasLayer {
|
|
|
|
|
layer_id: layer_id.to_string(),
|
|
|
|
|
resource_id: format!("draft-media:{media_id}"),
|
|
|
|
|
title: format!("{} 候选", ledger.asset_name),
|
|
|
|
|
media_ref: ledger
|
|
|
|
|
.candidate_media_ref
|
|
|
|
|
.clone()
|
|
|
|
|
.ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())?,
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
original_width: f64::from(*pixel_width),
|
|
|
|
|
original_height: f64::from(*pixel_height),
|
|
|
|
|
z_index,
|
|
|
|
|
group_id: None,
|
|
|
|
|
hidden: false,
|
|
|
|
|
locked: false,
|
|
|
|
|
flip_x: false,
|
|
|
|
|
flip_y: false,
|
|
|
|
|
});
|
|
|
|
|
draft.canvas.selected_layer_ids = vec![layer_id.to_string()];
|
|
|
|
|
draft.canvas.primary_selected_layer_id = Some(layer_id.to_string());
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !changed {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
draft.revision = draft
|
|
|
|
|
.revision
|
|
|
|
|
.checked_add(1)
|
|
|
|
|
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
|
|
|
|
|
validate_safe_revision(draft.revision, "草稿 revision")?;
|
|
|
|
|
draft.updated_at = asset_canvas_now();
|
|
|
|
|
write_asset_canvas_draft_locked(root, &draft)?;
|
|
|
|
|
Ok(Some(draft))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn normalized_request_fingerprint(
|
|
|
|
|
input: &GenerateAssetCanvasImageInput,
|
|
|
|
|
manifest: &GameCreationAppManifest,
|
|
|
|
@@ -1633,6 +1801,7 @@ fn validate_and_prepare_ledger(
|
|
|
|
|
staged_draft_revision: None,
|
|
|
|
|
candidate_media_ref: None,
|
|
|
|
|
candidate_layer_id: None,
|
|
|
|
|
candidate_layer_acknowledged_revision: None,
|
|
|
|
|
commit_result: None,
|
|
|
|
|
error_code: None,
|
|
|
|
|
archived_at: None,
|
|
|
|
@@ -3444,6 +3613,7 @@ pub(crate) async fn recover_asset_canvas_generations_at(
|
|
|
|
|
events: Vec::new(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
restore_unacknowledged_candidate_layers_at(root, &input.expected_project_id, &input.draft_id)?;
|
|
|
|
|
let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None)
|
|
|
|
|
.map_err(|_| sanitized_generation_error("configuration-missing"))?;
|
|
|
|
|
let api_mode = CanvasGenerationApiMode { api_key };
|
|
|
|
@@ -3904,6 +4074,7 @@ mod tests {
|
|
|
|
|
staged_draft_revision: None,
|
|
|
|
|
candidate_media_ref: None,
|
|
|
|
|
candidate_layer_id: None,
|
|
|
|
|
candidate_layer_acknowledged_revision: None,
|
|
|
|
|
commit_result: None,
|
|
|
|
|
error_code: None,
|
|
|
|
|
archived_at: None,
|
|
|
|
@@ -4046,15 +4217,111 @@ mod tests {
|
|
|
|
|
assert_eq!(candidate_layer.width, placeholder.width);
|
|
|
|
|
assert_eq!(candidate_layer.height, placeholder.height);
|
|
|
|
|
|
|
|
|
|
// Simulate the observed stale-autosave failure: the authoritative draft
|
|
|
|
|
// loses an unacknowledged candidate, then recovery restores it from the
|
|
|
|
|
// private ledger before the canvas is hydrated again.
|
|
|
|
|
let mut lost_draft = updated.clone();
|
|
|
|
|
lost_draft
|
|
|
|
|
.canvas
|
|
|
|
|
.layers
|
|
|
|
|
.retain(|layer| layer.layer_id != candidate_layer_id);
|
|
|
|
|
lost_draft.canvas.selected_layer_ids = vec![source_layer_id.clone()];
|
|
|
|
|
lost_draft.canvas.primary_selected_layer_id = Some(source_layer_id.clone());
|
|
|
|
|
write_asset_canvas_draft_locked(directory.path(), &lost_draft)
|
|
|
|
|
.expect("write draft without candidate");
|
|
|
|
|
let restored = restore_unacknowledged_candidate_layers_at(
|
|
|
|
|
directory.path(),
|
|
|
|
|
project_id,
|
|
|
|
|
&draft.draft_id,
|
|
|
|
|
)
|
|
|
|
|
.expect("restore unacknowledged candidate")
|
|
|
|
|
.expect("candidate restored");
|
|
|
|
|
assert!(restored
|
|
|
|
|
.canvas
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|layer| layer.layer_id == candidate_layer_id));
|
|
|
|
|
|
|
|
|
|
let mut stale_canvas = restored.canvas.clone();
|
|
|
|
|
stale_canvas
|
|
|
|
|
.layers
|
|
|
|
|
.retain(|layer| layer.layer_id != candidate_layer_id);
|
|
|
|
|
stale_canvas.selected_layer_ids = vec![source_layer_id.clone()];
|
|
|
|
|
stale_canvas.primary_selected_layer_id = Some(source_layer_id.clone());
|
|
|
|
|
let protected = update_asset_canvas_draft_at(
|
|
|
|
|
directory.path(),
|
|
|
|
|
&UpdateAssetCanvasDraftInput {
|
|
|
|
|
project_path: directory.path().to_string_lossy().into_owned(),
|
|
|
|
|
expected_project_id: project_id.to_string(),
|
|
|
|
|
draft_id: draft.draft_id.clone(),
|
|
|
|
|
expected_draft_revision: restored.revision,
|
|
|
|
|
status: AssetCanvasDraftStatus::Editing,
|
|
|
|
|
canvas: stale_canvas,
|
|
|
|
|
generations: restored.generations.clone(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("protect stale autosave")
|
|
|
|
|
.draft;
|
|
|
|
|
assert!(protected
|
|
|
|
|
.canvas
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|layer| layer.layer_id == candidate_layer_id));
|
|
|
|
|
|
|
|
|
|
let acknowledged = update_asset_canvas_draft_at(
|
|
|
|
|
directory.path(),
|
|
|
|
|
&UpdateAssetCanvasDraftInput {
|
|
|
|
|
project_path: directory.path().to_string_lossy().into_owned(),
|
|
|
|
|
expected_project_id: project_id.to_string(),
|
|
|
|
|
draft_id: draft.draft_id.clone(),
|
|
|
|
|
expected_draft_revision: protected.revision,
|
|
|
|
|
status: AssetCanvasDraftStatus::Editing,
|
|
|
|
|
canvas: protected.canvas.clone(),
|
|
|
|
|
generations: protected.generations.clone(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("acknowledge candidate")
|
|
|
|
|
.draft;
|
|
|
|
|
let acknowledged_ledger = read_generation_ledger(directory.path(), &input.generation_id)
|
|
|
|
|
.expect("read acknowledged ledger")
|
|
|
|
|
.expect("acknowledged ledger retained");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
acknowledged_ledger.candidate_layer_acknowledged_revision,
|
|
|
|
|
Some(acknowledged.revision)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut intentional_delete_canvas = acknowledged.canvas.clone();
|
|
|
|
|
intentional_delete_canvas
|
|
|
|
|
.layers
|
|
|
|
|
.retain(|layer| layer.layer_id != candidate_layer_id);
|
|
|
|
|
intentional_delete_canvas.selected_layer_ids = vec![source_layer_id.clone()];
|
|
|
|
|
intentional_delete_canvas.primary_selected_layer_id = Some(source_layer_id.clone());
|
|
|
|
|
let after_delete = update_asset_canvas_draft_at(
|
|
|
|
|
directory.path(),
|
|
|
|
|
&UpdateAssetCanvasDraftInput {
|
|
|
|
|
project_path: directory.path().to_string_lossy().into_owned(),
|
|
|
|
|
expected_project_id: project_id.to_string(),
|
|
|
|
|
draft_id: draft.draft_id.clone(),
|
|
|
|
|
expected_draft_revision: acknowledged.revision,
|
|
|
|
|
status: AssetCanvasDraftStatus::Editing,
|
|
|
|
|
canvas: intentional_delete_canvas,
|
|
|
|
|
generations: acknowledged.generations.clone(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("allow intentional candidate deletion after acknowledgement")
|
|
|
|
|
.draft;
|
|
|
|
|
assert!(!after_delete
|
|
|
|
|
.canvas
|
|
|
|
|
.layers
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|layer| layer.layer_id == candidate_layer_id));
|
|
|
|
|
|
|
|
|
|
let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id)
|
|
|
|
|
.expect("read ledger after restart")
|
|
|
|
|
.expect("placement ledger retained");
|
|
|
|
|
assert_eq!(recovered_ledger.source_layer_id, Some(source_layer_id));
|
|
|
|
|
assert_eq!(recovered_ledger.placeholder, Some(placeholder.clone()));
|
|
|
|
|
let recovered_draft =
|
|
|
|
|
read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id)
|
|
|
|
|
.expect("read draft after restart")
|
|
|
|
|
.expect("draft retained after restart");
|
|
|
|
|
let recovered_draft = after_delete;
|
|
|
|
|
let recovered_record = recovered_draft
|
|
|
|
|
.generations
|
|
|
|
|
.iter()
|
|
|
|
@@ -5824,6 +6091,7 @@ mod tests {
|
|
|
|
|
staged_draft_revision: None,
|
|
|
|
|
candidate_media_ref: None,
|
|
|
|
|
candidate_layer_id: None,
|
|
|
|
|
candidate_layer_acknowledged_revision: None,
|
|
|
|
|
commit_result: None,
|
|
|
|
|
error_code: None,
|
|
|
|
|
archived_at: None,
|
|
|
|
|