完善素材创作画布候选确认与资源画布
新增候选图层独立幂等确认命令与前端 FIFO 屏障 补齐生成候选恢复、JPEG EXIF 尺寸和资源依赖线视口处理 调整资源画布布局同步并更新定向测试、技术文档与排障记忆
This commit is contained in:
@@ -623,6 +623,14 @@ pub(crate) fn update_local_project_asset_canvas_draft(
|
||||
update_asset_canvas_draft_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn acknowledge_local_project_asset_canvas_candidate_layers(
|
||||
input: AcknowledgeAssetCanvasCandidateLayersInput,
|
||||
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
acknowledge_candidate_layers_at(&root, &input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_local_project_asset_canvas_images(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -396,11 +396,70 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TiffByteOrder {
|
||||
LittleEndian,
|
||||
BigEndian,
|
||||
}
|
||||
|
||||
fn read_tiff_u16(bytes: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option<u16> {
|
||||
let end = offset.checked_add(2)?;
|
||||
let value: [u8; 2] = bytes.get(offset..end)?.try_into().ok()?;
|
||||
Some(match byte_order {
|
||||
TiffByteOrder::LittleEndian => u16::from_le_bytes(value),
|
||||
TiffByteOrder::BigEndian => u16::from_be_bytes(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_tiff_u32(bytes: &[u8], offset: usize, byte_order: TiffByteOrder) -> Option<u32> {
|
||||
let end = offset.checked_add(4)?;
|
||||
let value: [u8; 4] = bytes.get(offset..end)?.try_into().ok()?;
|
||||
Some(match byte_order {
|
||||
TiffByteOrder::LittleEndian => u32::from_le_bytes(value),
|
||||
TiffByteOrder::BigEndian => u32::from_be_bytes(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_jpeg_exif_orientation(app1_payload: &[u8]) -> Option<u16> {
|
||||
let tiff = app1_payload.strip_prefix(b"Exif\0\0")?;
|
||||
let byte_order = match tiff.get(..2)? {
|
||||
b"II" => TiffByteOrder::LittleEndian,
|
||||
b"MM" => TiffByteOrder::BigEndian,
|
||||
_ => return None,
|
||||
};
|
||||
if read_tiff_u16(tiff, 2, byte_order)? != 42 {
|
||||
return None;
|
||||
}
|
||||
let ifd_offset = usize::try_from(read_tiff_u32(tiff, 4, byte_order)?).ok()?;
|
||||
let entry_count = usize::from(read_tiff_u16(tiff, ifd_offset, byte_order)?);
|
||||
let entries_start = ifd_offset.checked_add(2)?;
|
||||
let entries_end = entries_start.checked_add(entry_count.checked_mul(12)?)?;
|
||||
if entries_end > tiff.len() {
|
||||
return None;
|
||||
}
|
||||
for entry_index in 0..entry_count {
|
||||
let entry_offset = entries_start.checked_add(entry_index.checked_mul(12)?)?;
|
||||
if read_tiff_u16(tiff, entry_offset, byte_order)? != 0x0112 {
|
||||
continue;
|
||||
}
|
||||
if read_tiff_u16(tiff, entry_offset.checked_add(2)?, byte_order)? != 3
|
||||
|| read_tiff_u32(tiff, entry_offset.checked_add(4)?, byte_order)? != 1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let orientation = read_tiff_u16(tiff, entry_offset.checked_add(8)?, byte_order)?;
|
||||
return (1..=8).contains(&orientation).then_some(orientation);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if !bytes.starts_with(&[0xff, 0xd8]) {
|
||||
return None;
|
||||
}
|
||||
let mut index = 2usize;
|
||||
let mut exif_orientation = None;
|
||||
let mut dimensions = None;
|
||||
while index + 3 < bytes.len() {
|
||||
if bytes[index] != 0xff {
|
||||
index += 1;
|
||||
@@ -424,21 +483,28 @@ fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if segment_len < 2 || index.checked_add(segment_len)? > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
if matches!(
|
||||
marker,
|
||||
0xc0 | 0xc1
|
||||
| 0xc2
|
||||
| 0xc3
|
||||
| 0xc5
|
||||
| 0xc6
|
||||
| 0xc7
|
||||
| 0xc9
|
||||
| 0xca
|
||||
| 0xcb
|
||||
| 0xcd
|
||||
| 0xce
|
||||
| 0xcf
|
||||
) && segment_len >= 7
|
||||
if marker == 0xe1 && exif_orientation.is_none() {
|
||||
let payload_start = index.checked_add(2)?;
|
||||
let payload_end = index.checked_add(segment_len)?;
|
||||
exif_orientation = detect_jpeg_exif_orientation(bytes.get(payload_start..payload_end)?);
|
||||
}
|
||||
if dimensions.is_none()
|
||||
&& matches!(
|
||||
marker,
|
||||
0xc0 | 0xc1
|
||||
| 0xc2
|
||||
| 0xc3
|
||||
| 0xc5
|
||||
| 0xc6
|
||||
| 0xc7
|
||||
| 0xc9
|
||||
| 0xca
|
||||
| 0xcb
|
||||
| 0xcd
|
||||
| 0xce
|
||||
| 0xcf
|
||||
)
|
||||
&& segment_len >= 7
|
||||
{
|
||||
let height = u32::from(u16::from_be_bytes([
|
||||
*bytes.get(index + 3)?,
|
||||
@@ -448,11 +514,17 @@ fn detect_jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
*bytes.get(index + 5)?,
|
||||
*bytes.get(index + 6)?,
|
||||
]));
|
||||
return Some((width, height));
|
||||
dimensions = Some((width, height));
|
||||
}
|
||||
index += segment_len;
|
||||
}
|
||||
None
|
||||
dimensions.map(|(width, height)| {
|
||||
if matches!(exif_orientation, Some(5..=8)) {
|
||||
(height, width)
|
||||
} else {
|
||||
(width, height)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
@@ -626,6 +698,72 @@ mod tests {
|
||||
.expect("valid 1x1 png")
|
||||
}
|
||||
|
||||
fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec<u8> {
|
||||
let mut bytes = vec![0xff, 0xd8];
|
||||
if let Some(payload) = app1_payload {
|
||||
let segment_len = u16::try_from(payload.len() + 2).expect("APP1 length");
|
||||
bytes.extend_from_slice(&[0xff, 0xe1]);
|
||||
bytes.extend_from_slice(&segment_len.to_be_bytes());
|
||||
bytes.extend_from_slice(payload);
|
||||
}
|
||||
bytes.extend_from_slice(&[0xff, 0xc0, 0, 7, 8]);
|
||||
bytes.extend_from_slice(&height.to_be_bytes());
|
||||
bytes.extend_from_slice(&width.to_be_bytes());
|
||||
bytes.extend_from_slice(&[0xff, 0xd9]);
|
||||
bytes
|
||||
}
|
||||
|
||||
fn exif_orientation_payload(orientation: u16, little_endian: bool) -> Vec<u8> {
|
||||
let mut payload = b"Exif\0\0".to_vec();
|
||||
if little_endian {
|
||||
payload.extend_from_slice(b"II");
|
||||
payload.extend_from_slice(&42_u16.to_le_bytes());
|
||||
payload.extend_from_slice(&8_u32.to_le_bytes());
|
||||
payload.extend_from_slice(&1_u16.to_le_bytes());
|
||||
payload.extend_from_slice(&0x0112_u16.to_le_bytes());
|
||||
payload.extend_from_slice(&3_u16.to_le_bytes());
|
||||
payload.extend_from_slice(&1_u32.to_le_bytes());
|
||||
payload.extend_from_slice(&orientation.to_le_bytes());
|
||||
} else {
|
||||
payload.extend_from_slice(b"MM");
|
||||
payload.extend_from_slice(&42_u16.to_be_bytes());
|
||||
payload.extend_from_slice(&8_u32.to_be_bytes());
|
||||
payload.extend_from_slice(&1_u16.to_be_bytes());
|
||||
payload.extend_from_slice(&0x0112_u16.to_be_bytes());
|
||||
payload.extend_from_slice(&3_u16.to_be_bytes());
|
||||
payload.extend_from_slice(&1_u32.to_be_bytes());
|
||||
payload.extend_from_slice(&orientation.to_be_bytes());
|
||||
}
|
||||
payload.extend_from_slice(&[0, 0]);
|
||||
payload
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_dimensions_apply_little_and_big_endian_exif_rotation() {
|
||||
for (orientation, little_endian) in [(6, true), (8, false)] {
|
||||
let exif = exif_orientation_payload(orientation, little_endian);
|
||||
assert_eq!(
|
||||
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(&exif))),
|
||||
Some((20, 40))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_dimensions_keep_raw_size_for_normal_or_malformed_exif() {
|
||||
let normal = exif_orientation_payload(1, true);
|
||||
assert_eq!(
|
||||
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(&normal))),
|
||||
Some((40, 20))
|
||||
);
|
||||
|
||||
let malformed = b"Exif\0\0II\x2a\0\xff\xff\xff\x7f";
|
||||
assert_eq!(
|
||||
detect_jpeg_dimensions(&jpeg_bytes(40, 20, Some(malformed))),
|
||||
Some((40, 20))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_inspect_accepts_magic_bytes_without_trusting_extension() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
|
||||
@@ -2382,6 +2382,7 @@ fn main() {
|
||||
read_local_project_asset_canvas_draft,
|
||||
discover_local_project_asset_canvas_draft,
|
||||
update_local_project_asset_canvas_draft,
|
||||
acknowledge_local_project_asset_canvas_candidate_layers,
|
||||
import_local_project_asset_canvas_images,
|
||||
store_local_project_asset_canvas_media,
|
||||
stage_local_project_asset_canvas_image,
|
||||
|
||||
@@ -1560,12 +1560,7 @@ pub(crate) fn update_asset_canvas_draft_at(
|
||||
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
|
||||
validate_safe_revision(draft.revision, "草稿 revision")?;
|
||||
draft.status = input.status.clone();
|
||||
draft.canvas = protect_candidate_layers_before_frontend_update_at(
|
||||
root,
|
||||
&draft,
|
||||
draft.revision,
|
||||
&input.canvas,
|
||||
)?;
|
||||
draft.canvas = protect_candidate_layers_before_frontend_update_at(root, &draft, &input.canvas)?;
|
||||
// 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,
|
||||
|
||||
@@ -166,6 +166,21 @@ pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityResult {
|
||||
pub(crate) identity_scheme: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub(crate) struct AcknowledgeAssetCanvasCandidateLayersInput {
|
||||
pub(crate) project_path: String,
|
||||
pub(crate) expected_project_id: String,
|
||||
pub(crate) draft_id: String,
|
||||
pub(crate) layer_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AcknowledgeAssetCanvasCandidateLayersResult {
|
||||
pub(crate) draft: AssetCanvasDraft,
|
||||
}
|
||||
|
||||
pub(crate) struct RecoverAssetCanvasGenerationsExecution {
|
||||
pub(crate) result: RecoverAssetCanvasGenerationsResult,
|
||||
pub(crate) events: Vec<AssetCanvasCommittedEvent>,
|
||||
@@ -1496,7 +1511,6 @@ 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();
|
||||
@@ -1504,7 +1518,7 @@ pub(crate) fn protect_candidate_layers_before_frontend_update_at(
|
||||
if record.phase != AssetCanvasGenerationStatus::CandidateReady {
|
||||
continue;
|
||||
}
|
||||
let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else {
|
||||
let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else {
|
||||
continue;
|
||||
};
|
||||
if ledger.project_id != current_draft.project_id
|
||||
@@ -1525,20 +1539,74 @@ pub(crate) fn protect_candidate_layers_before_frontend_update_at(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if merged_canvas
|
||||
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 acknowledge_candidate_layers_at(
|
||||
root: &Path,
|
||||
input: &AcknowledgeAssetCanvasCandidateLayersInput,
|
||||
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
|
||||
validate_uuid_v4(&input.draft_id, "draftId")?;
|
||||
if input.layer_ids.len() > ASSET_CANVAS_MAX_LAYERS {
|
||||
return Err("候选图层确认数量已达上限".to_string());
|
||||
}
|
||||
for layer_id in &input.layer_ids {
|
||||
validate_plain_component(layer_id, "layerId", 512)?;
|
||||
}
|
||||
validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
|
||||
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
|
||||
let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
|
||||
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?
|
||||
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
|
||||
if draft.project_id != manifest.project_id || draft.draft_id != input.draft_id {
|
||||
return Err("素材画布候选确认身份不一致".to_string());
|
||||
}
|
||||
|
||||
let requested_layer_ids = input
|
||||
.layer_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<HashSet<_>>();
|
||||
let authoritative_layer_ids = draft
|
||||
.canvas
|
||||
.layers
|
||||
.iter()
|
||||
.map(|layer| layer.layer_id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
for record in &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 != 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 requested_layer_ids.contains(&layer_id) && authoritative_layer_ids.contains(layer_id) {
|
||||
ledger.candidate_layer_acknowledged_revision = Some(draft.revision);
|
||||
write_generation_ledger(root, &mut ledger)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(AcknowledgeAssetCanvasCandidateLayersResult { draft })
|
||||
}
|
||||
|
||||
pub(crate) fn restore_unacknowledged_candidate_layers_at(
|
||||
root: &Path,
|
||||
expected_project_id: &str,
|
||||
@@ -4350,29 +4418,66 @@ mod tests {
|
||||
.iter()
|
||||
.any(|layer| layer.layer_id == candidate_layer_id));
|
||||
|
||||
let acknowledged = update_asset_canvas_draft_at(
|
||||
let still_unacknowledged = read_generation_ledger(directory.path(), &input.generation_id)
|
||||
.expect("read protected candidate ledger")
|
||||
.expect("protected candidate ledger retained");
|
||||
assert_eq!(
|
||||
still_unacknowledged.candidate_layer_acknowledged_revision,
|
||||
None
|
||||
);
|
||||
|
||||
let ignored_acknowledgement = acknowledge_candidate_layers_at(
|
||||
directory.path(),
|
||||
&UpdateAssetCanvasDraftInput {
|
||||
&AcknowledgeAssetCanvasCandidateLayersInput {
|
||||
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(),
|
||||
layer_ids: vec![source_layer_id.clone(), "unknown-layer".to_string()],
|
||||
},
|
||||
)
|
||||
.expect("acknowledge candidate")
|
||||
.draft;
|
||||
.expect("ignore unknown and non-candidate layer acknowledgement");
|
||||
assert_eq!(ignored_acknowledgement.draft.revision, protected.revision);
|
||||
let ignored_ledger = read_generation_ledger(directory.path(), &input.generation_id)
|
||||
.expect("read ignored acknowledgement ledger")
|
||||
.expect("ignored acknowledgement ledger retained");
|
||||
assert_eq!(ignored_ledger.candidate_layer_acknowledged_revision, None);
|
||||
|
||||
let acknowledged = acknowledge_candidate_layers_at(
|
||||
directory.path(),
|
||||
&AcknowledgeAssetCanvasCandidateLayersInput {
|
||||
project_path: directory.path().to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
draft_id: draft.draft_id.clone(),
|
||||
layer_ids: vec![candidate_layer_id.to_string()],
|
||||
},
|
||||
)
|
||||
.expect("acknowledge candidate");
|
||||
assert_eq!(acknowledged.draft.revision, protected.revision);
|
||||
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)
|
||||
Some(protected.revision)
|
||||
);
|
||||
|
||||
let mut intentional_delete_canvas = acknowledged.canvas.clone();
|
||||
let repeated = acknowledge_candidate_layers_at(
|
||||
directory.path(),
|
||||
&AcknowledgeAssetCanvasCandidateLayersInput {
|
||||
project_path: directory.path().to_string_lossy().into_owned(),
|
||||
expected_project_id: project_id.to_string(),
|
||||
draft_id: draft.draft_id.clone(),
|
||||
layer_ids: vec![candidate_layer_id.to_string()],
|
||||
},
|
||||
)
|
||||
.expect("repeat candidate acknowledgement");
|
||||
assert_eq!(repeated.draft.revision, protected.revision);
|
||||
let repeated_ledger = read_generation_ledger(directory.path(), &input.generation_id)
|
||||
.expect("read repeated acknowledgement ledger")
|
||||
.expect("repeated acknowledgement ledger retained");
|
||||
assert_eq!(repeated_ledger, acknowledged_ledger);
|
||||
|
||||
let mut intentional_delete_canvas = acknowledged.draft.canvas.clone();
|
||||
intentional_delete_canvas
|
||||
.layers
|
||||
.retain(|layer| layer.layer_id != candidate_layer_id);
|
||||
@@ -4384,10 +4489,10 @@ mod tests {
|
||||
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,
|
||||
expected_draft_revision: acknowledged.draft.revision,
|
||||
status: AssetCanvasDraftStatus::Editing,
|
||||
canvas: intentional_delete_canvas,
|
||||
generations: acknowledged.generations.clone(),
|
||||
generations: acknowledged.draft.generations.clone(),
|
||||
},
|
||||
)
|
||||
.expect("allow intentional candidate deletion after acknowledgement")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -9,6 +9,7 @@ import type {
|
||||
ImageCanvasHostScope,
|
||||
ImageCanvasLocalImportResult,
|
||||
ImageCanvasMediaRef,
|
||||
ImageCanvasProjectPort,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
@@ -44,6 +45,10 @@ type DraftUpdateResult = {
|
||||
draft: ImageCanvasDraft;
|
||||
};
|
||||
|
||||
type CandidateLayerAcknowledgementResult = {
|
||||
draft: ImageCanvasDraft;
|
||||
};
|
||||
|
||||
type StoredMediaResult = {
|
||||
status: 'stored' | 'conflict';
|
||||
draftId: string;
|
||||
@@ -135,8 +140,13 @@ export type LocalAssetCommittedEvent = {
|
||||
occurredAt: number;
|
||||
};
|
||||
|
||||
export type TauriImageCanvasHostAdapter = ImageCanvasHostPort & {
|
||||
export type TauriImageCanvasHostAdapter = Omit<
|
||||
ImageCanvasHostPort,
|
||||
'project'
|
||||
> & {
|
||||
readonly kind: 'tauri';
|
||||
readonly project: ImageCanvasProjectPort &
|
||||
Required<Pick<ImageCanvasProjectPort, 'acknowledgeCandidateLayers'>>;
|
||||
readonly projectPath: string;
|
||||
readonly expectedProjectId: string;
|
||||
readMediaPreview(input: {
|
||||
@@ -511,6 +521,20 @@ export function createTauriImageCanvasHostAdapter(input: {
|
||||
return failure(error);
|
||||
}
|
||||
},
|
||||
async acknowledgeCandidateLayers(acknowledgement) {
|
||||
try {
|
||||
const result = await invokeInput<CandidateLayerAcknowledgementResult>(
|
||||
'acknowledge_local_project_asset_canvas_candidate_layers',
|
||||
{
|
||||
...baseScope(acknowledgement.scope),
|
||||
layerIds: acknowledgement.layerIds,
|
||||
},
|
||||
);
|
||||
return { status: 'ok', value: result.draft };
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
}
|
||||
},
|
||||
async discardDraft(update) {
|
||||
try {
|
||||
const result = await invokeInput<DraftUpdateResult>(
|
||||
|
||||
+16
-8
@@ -41,6 +41,7 @@ export type ResourceDependencyOverlayProps = {
|
||||
section?: ProjectResourceCanvasSection;
|
||||
visibleResourceIds: ReadonlySet<string>;
|
||||
cardSizeByResourceId?: ResourceCanvasCardSizeByResourceId;
|
||||
canvasViewport?: Readonly<Point & { scale: number }>;
|
||||
geometryRevision?: string;
|
||||
};
|
||||
|
||||
@@ -460,6 +461,7 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
ResourceDependencyOverlayProps
|
||||
>(function ResourceDependencyOverlay(
|
||||
{
|
||||
canvasViewport,
|
||||
cardSizeByResourceId,
|
||||
geometryRevision = '',
|
||||
graph,
|
||||
@@ -492,10 +494,6 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
useLayoutEffect(() => {
|
||||
const plane = overlayRef.current?.parentElement;
|
||||
const viewport = plane?.closest<HTMLElement>(SECTION_SCROLL_SELECTOR);
|
||||
if (plane?.closest('.game-resource-canvas--dependency')) {
|
||||
setLogicalViewport(null);
|
||||
return undefined;
|
||||
}
|
||||
if (!plane || !viewport) {
|
||||
setLogicalViewport(null);
|
||||
return undefined;
|
||||
@@ -504,16 +502,26 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
const measure = () => {
|
||||
frameId = null;
|
||||
const parsedScale = Number(plane.dataset.resourceSectionScale);
|
||||
const scale =
|
||||
const legacyScale =
|
||||
Number.isFinite(parsedScale) && parsedScale > 0 ? parsedScale : 1;
|
||||
const transformedViewport = plane.closest(
|
||||
'.game-resource-canvas--dependency',
|
||||
)
|
||||
? canvasViewport
|
||||
: undefined;
|
||||
const scale = transformedViewport?.scale ?? legacyScale;
|
||||
const viewportRect = viewport.getBoundingClientRect();
|
||||
const width = viewport.clientWidth || viewportRect.width;
|
||||
const height = viewport.clientHeight || viewportRect.height;
|
||||
const next =
|
||||
width > 0 && height > 0
|
||||
? {
|
||||
x: viewport.scrollLeft / scale,
|
||||
y: viewport.scrollTop / scale,
|
||||
x: transformedViewport
|
||||
? -transformedViewport.x / transformedViewport.scale
|
||||
: viewport.scrollLeft / scale,
|
||||
y: transformedViewport
|
||||
? -transformedViewport.y / transformedViewport.scale
|
||||
: viewport.scrollTop / scale,
|
||||
width: width / scale,
|
||||
height: height / scale,
|
||||
}
|
||||
@@ -545,7 +553,7 @@ export const ResourceDependencyOverlay = forwardRef<
|
||||
viewport.removeEventListener('scroll', scheduleMeasure);
|
||||
window.removeEventListener('resize', scheduleMeasure);
|
||||
};
|
||||
}, [geometryRevision]);
|
||||
}, [canvasViewport, geometryRevision]);
|
||||
|
||||
const rectByResourceId = useMemo(() => {
|
||||
const result = new Map<string, Rect>();
|
||||
|
||||
@@ -4433,6 +4433,7 @@ export default function ProjectDevelopmentView({
|
||||
cardSizeByResourceId={
|
||||
resourceCardSizeByResourceId
|
||||
}
|
||||
canvasViewport={activeResourceCanvasViewport}
|
||||
geometryRevision={`${resourceCanvasNavigationBounds.width}@${resourceCanvasNavigationBounds.height}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
+3
-13
@@ -32,7 +32,6 @@ type LayoutScope = {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
mode: ProjectResourceCanvasLayoutMode;
|
||||
resourceSignature: string;
|
||||
};
|
||||
|
||||
type ManualLayoutWriteIntent = {
|
||||
@@ -59,9 +58,8 @@ function createScopeKey(
|
||||
projectPath: string,
|
||||
projectId: string,
|
||||
mode: ProjectResourceCanvasLayoutMode,
|
||||
resourceSignature: string,
|
||||
) {
|
||||
return JSON.stringify([projectPath, projectId, mode, resourceSignature]);
|
||||
return JSON.stringify([projectPath, projectId, mode]);
|
||||
}
|
||||
|
||||
export function createResourceSignature(resources: ResourceCanvasItem[]) {
|
||||
@@ -239,12 +237,7 @@ export function useProjectResourceCanvasLayout({
|
||||
() => [createResourceSignature(resources), topologySignature].join('\n'),
|
||||
[resources, topologySignature],
|
||||
);
|
||||
const scopeKey = createScopeKey(
|
||||
projectPath,
|
||||
projectId,
|
||||
mode,
|
||||
resourceSignature,
|
||||
);
|
||||
const scopeKey = createScopeKey(projectPath, projectId, mode);
|
||||
const fallback = useMemo(() => {
|
||||
const empty = createEmptyResourceCanvasLayout(projectId, mode);
|
||||
return initializationReady || renderFallbackWhileBlocked
|
||||
@@ -277,7 +270,6 @@ export function useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode,
|
||||
resourceSignature,
|
||||
});
|
||||
const writeQueueRef = useRef<LayoutWriteIntent[]>([]);
|
||||
const activeWriteIntentRef = useRef<LayoutWriteIntent | null>(null);
|
||||
@@ -604,7 +596,6 @@ export function useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
projectId,
|
||||
mode,
|
||||
resourceSignature,
|
||||
};
|
||||
scopeRef.current = scope;
|
||||
initializedScopeEpochRef.current = null;
|
||||
@@ -697,7 +688,6 @@ export function useProjectResourceCanvasLayout({
|
||||
projectPath,
|
||||
rebuildOptimisticLayout,
|
||||
rederiveAutomaticPositions,
|
||||
resourceSignature,
|
||||
scopeKey,
|
||||
]);
|
||||
|
||||
@@ -824,7 +814,7 @@ export function useProjectResourceCanvasLayout({
|
||||
allResourcesPositioned;
|
||||
|
||||
return {
|
||||
layout: scopeMatches ? layout : fallback,
|
||||
layout: ready ? layout : fallback,
|
||||
notice,
|
||||
saving,
|
||||
ready,
|
||||
|
||||
@@ -726,6 +726,86 @@ describe('ResourceDependencyOverlay', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('culls paged dependency edges from the transformed canvas viewport', async () => {
|
||||
const graph = graphFixture();
|
||||
const originalGetBoundingClientRect =
|
||||
HTMLElement.prototype.getBoundingClientRect;
|
||||
const getBoundingClientRect = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getPagedCanvasRect(this: HTMLElement) {
|
||||
if (this.dataset.resourceSectionScroll === 'art') {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 600,
|
||||
bottom: 240,
|
||||
width: 600,
|
||||
height: 240,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
return originalGetBoundingClientRect.call(this);
|
||||
});
|
||||
const view = (canvasViewport: { x: number; y: number; scale: number }) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{ className: 'game-resource-canvas--dependency' },
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-resource-section-scroll': 'art' },
|
||||
React.createElement(
|
||||
'div',
|
||||
{ className: 'game-resource-page-world' },
|
||||
React.createElement(ResourceDependencyOverlay, {
|
||||
graph,
|
||||
positions: [
|
||||
position('source:one', 0, 0),
|
||||
position('target:one', 0, 320),
|
||||
],
|
||||
section: 'art',
|
||||
visibleResourceIds: new Set(['source:one', 'target:one']),
|
||||
canvasViewport,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const rendered = render(view({ x: 0, y: 0, scale: 1 }));
|
||||
const overlay = await screen.findByTestId(
|
||||
'resource-dependency-overlay-art',
|
||||
);
|
||||
const selector = '[data-edge-kind="asset-reference"]';
|
||||
await waitFor(() => {
|
||||
expect(overlay.dataset.logicalViewport).toBe('0,0,600,240');
|
||||
expect(
|
||||
overlay
|
||||
.querySelector(selector)
|
||||
?.getAttribute('data-edge-continuation'),
|
||||
).toBe('outgoing');
|
||||
});
|
||||
|
||||
rendered.rerender(view({ x: 0, y: -180, scale: 1 }));
|
||||
await waitFor(() => {
|
||||
expect(overlay.dataset.logicalViewport).toBe('0,180,600,240');
|
||||
expect(
|
||||
overlay
|
||||
.querySelector(selector)
|
||||
?.getAttribute('data-edge-continuation'),
|
||||
).toBe('incoming');
|
||||
});
|
||||
|
||||
rendered.rerender(view({ x: 0, y: -1_000, scale: 2 }));
|
||||
await waitFor(() => {
|
||||
expect(overlay.dataset.logicalViewport).toBe('0,500,300,120');
|
||||
expect(overlay.querySelector(selector)).toBeNull();
|
||||
});
|
||||
} finally {
|
||||
getBoundingClientRect.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps drag previews in plane-local coordinates', async () => {
|
||||
const getBoundingClientRect = vi
|
||||
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
import {
|
||||
AssetCanvasSurface,
|
||||
generationAspectRatioForOriginalImage,
|
||||
resolveQuickEditPanelPosition,
|
||||
type RenderAssetCanvasImage,
|
||||
resolveQuickEditPanelPosition,
|
||||
shouldApplyAssetCanvasDraftCandidate,
|
||||
} from '../src/features/asset-canvas/AssetCanvasSurface';
|
||||
import {
|
||||
@@ -175,6 +175,7 @@ function memoryHost(input?: {
|
||||
initialDraft?: ImageCanvasDraft | null;
|
||||
recoverGate?: Deferred<unknown>;
|
||||
recoverImagesGate?: Deferred<unknown>;
|
||||
acknowledgementGate?: Deferred<void>;
|
||||
importGate?: Deferred<unknown>;
|
||||
commitGate?: Deferred<void>;
|
||||
generationGate?: Deferred<void>;
|
||||
@@ -198,6 +199,9 @@ function memoryHost(input?: {
|
||||
| ((progress: ImageCanvasGenerationProgress) => void)
|
||||
| undefined;
|
||||
const updates: ImageCanvasDraftCanvas[] = [];
|
||||
const acknowledgementCalls: string[][] = [];
|
||||
const acknowledgedLayerIds = new Set<string>();
|
||||
const acknowledgementWrites = vi.fn();
|
||||
const commits: Array<{
|
||||
commitId: string;
|
||||
idempotencyKey: string;
|
||||
@@ -346,6 +350,21 @@ function memoryHost(input?: {
|
||||
return { status: 'ok', value: draft };
|
||||
},
|
||||
updateDraft,
|
||||
async acknowledgeCandidateLayers({ layerIds }) {
|
||||
acknowledgementCalls.push([...layerIds]);
|
||||
await input?.acknowledgementGate?.promise;
|
||||
if (!draft) throw new Error('draft missing');
|
||||
const newlyAcknowledged = layerIds.filter(
|
||||
(layerId) => !acknowledgedLayerIds.has(layerId),
|
||||
);
|
||||
if (newlyAcknowledged.length) {
|
||||
newlyAcknowledged.forEach((layerId) =>
|
||||
acknowledgedLayerIds.add(layerId),
|
||||
);
|
||||
acknowledgementWrites();
|
||||
}
|
||||
return { status: 'ok' as const, value: draft };
|
||||
},
|
||||
discardDraft,
|
||||
},
|
||||
asset: {
|
||||
@@ -525,7 +544,8 @@ function memoryHost(input?: {
|
||||
revision: draft.revision + 1,
|
||||
generations: [
|
||||
...draft.generations.filter(
|
||||
(record) => record.generationId !== generationInput.generationId,
|
||||
(record) =>
|
||||
record.generationId !== generationInput.generationId,
|
||||
),
|
||||
failedGeneration,
|
||||
],
|
||||
@@ -742,6 +762,8 @@ function memoryHost(input?: {
|
||||
selectedCandidateCommits,
|
||||
generationCalls,
|
||||
updates,
|
||||
acknowledgementCalls,
|
||||
acknowledgementWrites,
|
||||
revokedSubscriptions,
|
||||
nativeImportCalls,
|
||||
loadDraft,
|
||||
@@ -1415,6 +1437,328 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
expect(memory.recoverImages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('初始 hydrate 的候选图层先确认再允许删除持久化', async () => {
|
||||
const acknowledgementGate = deferred<void>();
|
||||
const initialDraft = draftFixture(scope, keyboardCanvas());
|
||||
const candidateLayer: ImageCanvasDraftCanvas['layers'][number] = {
|
||||
...initialDraft.canvas.layers[0]!,
|
||||
layerId: 'hydrated-candidate-layer',
|
||||
resourceId: 'draft-media:hydrated-candidate',
|
||||
title: '初始恢复候选图',
|
||||
mediaRef: {
|
||||
kind: 'draft-media',
|
||||
mediaId: 'hydrated-candidate',
|
||||
mediaType: 'image/png',
|
||||
sha256: '8'.repeat(64),
|
||||
byteLength: 4,
|
||||
pixelWidth: 64,
|
||||
pixelHeight: 64,
|
||||
},
|
||||
zIndex: initialDraft.canvas.layers.length,
|
||||
};
|
||||
const candidateDraft: ImageCanvasDraft = {
|
||||
...initialDraft,
|
||||
canvas: {
|
||||
...initialDraft.canvas,
|
||||
layers: [...initialDraft.canvas.layers, candidateLayer],
|
||||
},
|
||||
generations: [
|
||||
{
|
||||
generationId: 'hydrated-generation',
|
||||
intentId: 'hydrated-intent',
|
||||
phase: 'candidate-ready',
|
||||
referenceResourceIds: [],
|
||||
outputAssetId: null,
|
||||
sourceLayerId: null,
|
||||
placeholder: null,
|
||||
errorCode: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const memory = memoryHost({
|
||||
initialDraft: candidateDraft,
|
||||
acknowledgementGate,
|
||||
});
|
||||
renderSurface(memory.host);
|
||||
|
||||
const candidateButton = await screen.findByRole('button', {
|
||||
name: '选择图层 初始恢复候选图',
|
||||
});
|
||||
await waitFor(() => expect(memory.acknowledgementCalls).toHaveLength(1));
|
||||
expect(memory.getDraft()?.revision).toBe(candidateDraft.revision);
|
||||
expect(memory.updateDraft).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(candidateButton);
|
||||
const quickEdit = await screen.findByRole('region', {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' }));
|
||||
await act(
|
||||
async () => new Promise((resolve) => window.setTimeout(resolve, 220)),
|
||||
);
|
||||
expect(memory.updateDraft).not.toHaveBeenCalled();
|
||||
expect(memory.getDraft()?.revision).toBe(candidateDraft.revision);
|
||||
|
||||
await act(async () => acknowledgementGate.resolve());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
memory.updates.some((canvas) =>
|
||||
canvas.layers.every(
|
||||
(layer) => layer.layerId !== candidateLayer.layerId,
|
||||
),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(memory.acknowledgementWrites).toHaveBeenCalledTimes(1);
|
||||
expect(memory.updateDraft).toHaveBeenCalledTimes(1);
|
||||
expect(memory.getDraft()?.revision).toBe(candidateDraft.revision + 1);
|
||||
});
|
||||
|
||||
it('重复打开已确认候选只执行幂等确认且不推进草稿 revision', async () => {
|
||||
const initialDraft = draftFixture(scope, keyboardCanvas());
|
||||
const candidateLayer: ImageCanvasDraftCanvas['layers'][number] = {
|
||||
...initialDraft.canvas.layers[0]!,
|
||||
layerId: 'reopened-candidate-layer',
|
||||
resourceId: 'draft-media:reopened-candidate',
|
||||
title: '重复打开候选图',
|
||||
mediaRef: {
|
||||
kind: 'draft-media',
|
||||
mediaId: 'reopened-candidate',
|
||||
mediaType: 'image/png',
|
||||
sha256: '7'.repeat(64),
|
||||
byteLength: 4,
|
||||
pixelWidth: 64,
|
||||
pixelHeight: 64,
|
||||
},
|
||||
zIndex: initialDraft.canvas.layers.length,
|
||||
};
|
||||
const candidateDraft: ImageCanvasDraft = {
|
||||
...initialDraft,
|
||||
canvas: {
|
||||
...initialDraft.canvas,
|
||||
layers: [...initialDraft.canvas.layers, candidateLayer],
|
||||
},
|
||||
generations: [
|
||||
{
|
||||
generationId: 'reopened-generation',
|
||||
intentId: 'reopened-intent',
|
||||
phase: 'candidate-ready',
|
||||
referenceResourceIds: [],
|
||||
outputAssetId: null,
|
||||
sourceLayerId: null,
|
||||
placeholder: null,
|
||||
errorCode: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const memory = memoryHost({ initialDraft: candidateDraft });
|
||||
const first = renderSurface(memory.host);
|
||||
await waitFor(() => expect(memory.acknowledgementCalls).toHaveLength(1));
|
||||
expect(memory.acknowledgementWrites).toHaveBeenCalledTimes(1);
|
||||
expect(memory.updateDraft).not.toHaveBeenCalled();
|
||||
expect(memory.getDraft()?.revision).toBe(candidateDraft.revision);
|
||||
|
||||
first.unmount();
|
||||
renderSurface(memory.host);
|
||||
await waitFor(() => expect(memory.acknowledgementCalls).toHaveLength(2));
|
||||
expect(memory.acknowledgementWrites).toHaveBeenCalledTimes(1);
|
||||
expect(memory.updateDraft).not.toHaveBeenCalled();
|
||||
expect(memory.getDraft()?.revision).toBe(candidateDraft.revision);
|
||||
});
|
||||
|
||||
it('后台恢复新增候选图层时保留本地画布并增量挂载候选图', async () => {
|
||||
const initialDraft = draftFixture(scope, keyboardCanvas());
|
||||
const recoveredLayer: ImageCanvasDraftCanvas['layers'][number] = {
|
||||
...initialDraft.canvas.layers[0]!,
|
||||
layerId: 'recovered-candidate-layer',
|
||||
resourceId: 'draft-media:recovered-candidate',
|
||||
title: '后台恢复候选图',
|
||||
mediaRef: {
|
||||
kind: 'draft-media',
|
||||
mediaId: 'recovered-candidate',
|
||||
mediaType: 'image/png',
|
||||
sha256: '9'.repeat(64),
|
||||
byteLength: 4,
|
||||
pixelWidth: 64,
|
||||
pixelHeight: 64,
|
||||
},
|
||||
x: 120,
|
||||
y: 80,
|
||||
width: 64,
|
||||
height: 64,
|
||||
originalWidth: 64,
|
||||
originalHeight: 64,
|
||||
zIndex: initialDraft.canvas.layers.length,
|
||||
};
|
||||
const recoveredDraft: ImageCanvasDraft = {
|
||||
...initialDraft,
|
||||
revision: 1,
|
||||
updatedAt: 2,
|
||||
canvas: {
|
||||
...initialDraft.canvas,
|
||||
layers: [...initialDraft.canvas.layers, recoveredLayer],
|
||||
selectedLayerIds: [recoveredLayer.layerId],
|
||||
primarySelectedLayerId: recoveredLayer.layerId,
|
||||
},
|
||||
generations: [
|
||||
{
|
||||
generationId: 'recovered-generation',
|
||||
intentId: 'recovered-intent',
|
||||
phase: 'candidate-ready',
|
||||
referenceResourceIds: [],
|
||||
outputAssetId: null,
|
||||
sourceLayerId: null,
|
||||
placeholder: null,
|
||||
errorCode: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const memory = memoryHost({ initialDraft });
|
||||
renderSurface(memory.host);
|
||||
await screen.findByText('画布可编辑');
|
||||
await waitFor(() => expect(memory.recoverImages).toHaveBeenCalledTimes(1));
|
||||
|
||||
memory.setDraft(recoveredDraft);
|
||||
act(() => {
|
||||
memory.emitRecoveryProgress({
|
||||
intentId: 'recovered-intent',
|
||||
generationId: 'recovered-generation',
|
||||
phase: 'candidate-ready',
|
||||
progress: 100,
|
||||
errorCode: null,
|
||||
draftRevision: recoveredDraft.revision,
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: '选择图层 后台恢复候选图',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '选择图层 第一层' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '选择图层 第二层' }),
|
||||
).toBeTruthy();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
memory.acknowledgementCalls.some((layerIds) =>
|
||||
layerIds.includes(recoveredLayer.layerId),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '选择图层 后台恢复候选图',
|
||||
}),
|
||||
);
|
||||
const quickEdit = await screen.findByRole('region', {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
fireEvent.click(within(quickEdit).getByRole('button', { name: '删除' }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
memory.updates.some((canvas) =>
|
||||
canvas.layers.every(
|
||||
(layer) => layer.layerId !== recoveredLayer.layerId,
|
||||
),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(
|
||||
memory.acknowledgementCalls.some((layerIds) =>
|
||||
layerIds.includes(recoveredLayer.layerId),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(memory.updateDraft).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('候选确认未完成时设为最终图会等待确认屏障', async () => {
|
||||
const acknowledgementGate = deferred<void>();
|
||||
const refineScope: ImageCanvasHostScope = {
|
||||
...scope,
|
||||
intent: 'refine',
|
||||
sourceAssetId: 'source-asset',
|
||||
};
|
||||
const sourceLayer: ImageCanvasDraftCanvas['layers'][number] = {
|
||||
...keyboardCanvas().layers[0]!,
|
||||
layerId: 'barrier-source-layer',
|
||||
resourceId: 'local-asset:source-asset',
|
||||
title: '入口图片',
|
||||
mediaRef: { kind: 'project-asset', assetId: 'source-asset' },
|
||||
};
|
||||
const candidateLayer: ImageCanvasDraftCanvas['layers'][number] = {
|
||||
...keyboardCanvas().layers[1]!,
|
||||
layerId: 'barrier-candidate-layer',
|
||||
resourceId: 'draft-media:barrier-candidate',
|
||||
title: '待确认候选图',
|
||||
mediaRef: {
|
||||
kind: 'draft-media',
|
||||
mediaId: 'barrier-candidate',
|
||||
mediaType: 'image/png',
|
||||
sha256: '6'.repeat(64),
|
||||
byteLength: 4,
|
||||
pixelWidth: 64,
|
||||
pixelHeight: 64,
|
||||
},
|
||||
};
|
||||
const initialDraft: ImageCanvasDraft = {
|
||||
...draftFixture(refineScope, {
|
||||
...emptyCanvas(),
|
||||
layers: [sourceLayer, candidateLayer],
|
||||
}),
|
||||
generations: [
|
||||
{
|
||||
generationId: 'barrier-generation',
|
||||
intentId: 'barrier-intent',
|
||||
phase: 'candidate-ready',
|
||||
referenceResourceIds: [],
|
||||
outputAssetId: null,
|
||||
sourceLayerId: sourceLayer.layerId,
|
||||
placeholder: null,
|
||||
errorCode: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const memory = memoryHost({ initialDraft, acknowledgementGate });
|
||||
renderSurface(memory.host, refineScope);
|
||||
|
||||
const candidateButton = await screen.findByRole('button', {
|
||||
name: '选择图层 待确认候选图',
|
||||
});
|
||||
await waitFor(() => expect(memory.acknowledgementCalls).toHaveLength(1));
|
||||
fireEvent.click(candidateButton);
|
||||
const quickEdit = await screen.findByRole('region', {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(quickEdit).getByRole('button', { name: '设为最终图' }),
|
||||
);
|
||||
|
||||
await act(async () => Promise.resolve());
|
||||
expect(memory.selectedCandidateCommits).toHaveLength(0);
|
||||
expect(memory.getDraft()?.revision).toBe(initialDraft.revision);
|
||||
|
||||
await act(async () => acknowledgementGate.resolve());
|
||||
await waitFor(() =>
|
||||
expect(memory.selectedCandidateCommits).toHaveLength(1),
|
||||
);
|
||||
expect(memory.selectedCandidateCommits[0]?.sourceLayerId).toBe(
|
||||
candidateLayer.layerId,
|
||||
);
|
||||
});
|
||||
|
||||
it('保留可编辑名称,并把新建资源用途限制为中文固定选项', async () => {
|
||||
const memory = memoryHost();
|
||||
renderSurface(memory.host);
|
||||
@@ -3625,6 +3969,38 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
expect(invokeSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('Tauri adapter 使用独立命令幂等确认候选且不传 expected revision', async () => {
|
||||
const draft = draftFixture(scope, keyboardCanvas());
|
||||
const invokeSpy = vi.fn(async () => ({ draft }));
|
||||
const adapter = createTauriImageCanvasHostAdapter({
|
||||
projectPath: '/fixture/project',
|
||||
expectedProjectId: scope.projectId,
|
||||
expectedHostRevision: 0,
|
||||
invoke: invokeSpy as unknown as <T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<T>,
|
||||
});
|
||||
|
||||
const result = await adapter.project.acknowledgeCandidateLayers({
|
||||
scope,
|
||||
layerIds: ['candidate-one', 'candidate-two'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: 'ok', value: draft });
|
||||
expect(invokeSpy).toHaveBeenCalledWith(
|
||||
'acknowledge_local_project_asset_canvas_candidate_layers',
|
||||
{
|
||||
input: {
|
||||
projectPath: '/fixture/project',
|
||||
expectedProjectId: scope.projectId,
|
||||
draftId: scope.draftId,
|
||||
layerIds: ['candidate-one', 'candidate-two'],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('Tauri adapter 不向生成与恢复命令透传登录 Token 或 External API Key', async () => {
|
||||
const accessToken = 'session-token-must-stay-ephemeral';
|
||||
setStoredAuthAccessToken(accessToken);
|
||||
|
||||
@@ -480,7 +480,7 @@ describe('useProjectResourceCanvasLayout', () => {
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
it('starts a new signed scope and ignores the old layout read when resources change', async () => {
|
||||
it('keeps the initial layout read and coordinates the latest resources in the same scope', async () => {
|
||||
const resourceA = resource('resource-a');
|
||||
const resourceB = resource('resource-b');
|
||||
const resolveReads: Array<(layout: ProjectResourceCanvasLayout) => void> =
|
||||
@@ -530,16 +530,8 @@ describe('useProjectResourceCanvasLayout', () => {
|
||||
await waitFor(() => expect(resolveReads).toHaveLength(1));
|
||||
|
||||
rerender({ resources: [resourceA, resourceB] });
|
||||
await waitFor(() => expect(resolveReads).toHaveLength(2));
|
||||
await act(async () => {
|
||||
resolveReads[0]?.(
|
||||
persistedLayout('dependency', 99, [position('resource-a', 900, 900)]),
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(updates).toHaveLength(0);
|
||||
await act(async () => {
|
||||
resolveReads[1]?.(
|
||||
persistedLayout('dependency', 1, [position('resource-a', 10, 20)]),
|
||||
);
|
||||
await Promise.resolve();
|
||||
@@ -560,10 +552,10 @@ describe('useProjectResourceCanvasLayout', () => {
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'read_local_project_resource_canvas_layout',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not let an old in-flight layout CAS block or overwrite a new signed scope', async () => {
|
||||
it('keeps an in-flight manual CAS ahead of resource coordination in the same scope', async () => {
|
||||
const resourceA = resource('resource-a');
|
||||
const resourceB = resource('resource-b');
|
||||
const updates: Array<{
|
||||
@@ -629,31 +621,32 @@ describe('useProjectResourceCanvasLayout', () => {
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(updates).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstUpdate?.({
|
||||
status: 'updated',
|
||||
layout: persistedLayout(
|
||||
'dependency',
|
||||
2,
|
||||
structuredClone(updates[0]!.positions),
|
||||
),
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(updates).toHaveLength(2));
|
||||
expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([
|
||||
1, 1,
|
||||
1, 2,
|
||||
]);
|
||||
expect(
|
||||
updates[1]?.positions.some(
|
||||
({ resourceId }) => resourceId === 'resource-b',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveFirstUpdate?.({
|
||||
status: 'conflict',
|
||||
layout: persistedLayout(
|
||||
'dependency',
|
||||
2,
|
||||
structuredClone(updates[1]!.positions),
|
||||
),
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.layout.positions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ resourceId: 'resource-a', x: 10, y: 20 }),
|
||||
expect.objectContaining({ resourceId: 'resource-a', x: 100, y: 30 }),
|
||||
expect.objectContaining({ resourceId: 'resource-b' }),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -4839,6 +4839,13 @@
|
||||
- 门禁:同一资源存在 prepared 或 reconciliation 事务时,新的“设为正式图”提交必须先被拒绝并引导安全恢复,不能继续制造另一笔可能覆盖旧结果的提交。`committed/rolled-back/superseded` 是可继续后续提交的终态。
|
||||
- 关联坑:正式文件路径会追加 commitId,重新打开 refine 时不能把整个文件 stem 当作下一次素材名,否则多次精修后超过 80 字符并阻塞提交。路径只推导显示名,且必须循环剥离历史 `--<uuid>` 后缀;确定性名称/用途校验要发生在读取候选与 staging 前,并按输入错误处理而不是进入事务恢复。
|
||||
|
||||
## 素材画布候选确认不能复用普通草稿保存(2026-08-23)
|
||||
|
||||
- 现象:候选挂载后用 fire-and-forget `updateDraft` 确认时,候选本身不增加前端 document version;用户立即设为最终图、导入、生成、归档或放弃草稿会携带旧 revision 与隐藏确认写入竞态。重新打开含 `candidate-ready` 的草稿还会重复保存同一画布、无意义推进 revision,并制造多窗口冲突。
|
||||
- 处理:候选确认使用独立幂等宿主操作,在现有草稿锁内只更新匹配当前项目、草稿、generation、仍存在权威图层且尚未确认的私有 ledger,记录当前草稿 revision,但不改写草稿或推进 revision。普通草稿 update 只保护未确认候选,不再顺带确认。
|
||||
- 前端门禁:确认任务进入 autosave FIFO,连续候选 ID 合并并串行处理;所有消费 draft revision 的提交、导入、生成、归档和 discard 必须先等待确认屏障。确认失败不得继续 revision-sensitive 操作,重复 hydrate 可以重发但后端必须零写入。
|
||||
- 验证:前端覆盖确认 pending 时立即设为最终图和删除,断言提交 / update 在确认完成前均未发生;重复打开不调用 `updateDraft` 且 revision 不变。Rust 覆盖首次确认、重复确认、未知或非候选 ID、普通 update 不确认,以及确认后显式删除。
|
||||
|
||||
## 隔离 Codex app-server 会误吃代理的 ChatGPT 额度头(2026-08-20)
|
||||
|
||||
- 现象:同一自定义 Responses endpoint 和 API Key 直接 HTTP 为 200,普通用户 HOME 下的 smoke 也完成,但隔离 `CODEX_HOME/HOME` 的 app-server 在真正发请求前返回 `usageLimitExceeded`,并投影 credits balance 0。
|
||||
|
||||
@@ -910,7 +910,7 @@ cancelling
|
||||
| A34 | 同资源并发正式提交 | 同一 project/draft/asset 存在 prepared 或 reconciliation 事务时再次提交 | 拒绝新提交并要求先安全恢复;已 committed/rolled-back/superseded 事务不阻塞后续提交 |
|
||||
| A35 | 精修文件名包含历史提交后缀 | 后续精修重新打开当前 `localPath`,或再次生成 / 设为最终图 | 统一剥离文件名末尾一个或多个 `--<uuid>` 后缀并规范化为合法 1..=80 字符显示名;生成与最终提交使用同一结果 |
|
||||
| A36 | 确定性提交参数无效 | 候选提交名称或用途在校验阶段失败 | 在读取候选、staging、transaction 或 ledger 写入前零副作用失败;UI 作为输入校验错误允许继续编辑,不触发安全恢复 |
|
||||
| A37 | 候选首次确认 | 生成完成后与旧 autosave 并发 | 前端先同步 authoritative layers 并保存确认;确认前后端把未确认候选层合回旧保存,重启恢复可从私有 ledger 重建候选层,确认后的显式删除仍允许 |
|
||||
| A37 | 候选首次确认 | 生成完成后与旧 autosave 并发,或重复打开已确认候选 | 前端把候选确认排入草稿保存 FIFO,并在提交、导入、生成、归档和放弃草稿前等待确认屏障;Tauri 在草稿锁内只为当前权威草稿中仍存在且尚未确认的候选更新私有 ledger,不改写草稿或推进 revision。普通 update 在确认前继续把候选层合回旧保存,重复确认无写入,确认后的显式删除仍允许 |
|
||||
| A38 | 稳定运行入口 | 精修替换已在游戏源码中引用的图片,或继续精修旧版本事务创建的资源 | manifest 指向不可变正式版本,同时原稳定入口路径不变并刷新为新版本字节;新事务可从旧事务 `manifest.before.json` 迁移稳定入口身份,幂等重放和事务恢复会修复缺失或不匹配入口,游戏源码不需要改路径 |
|
||||
|
||||
阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。
|
||||
|
||||
@@ -193,6 +193,10 @@ export interface ImageCanvasProjectPort {
|
||||
canvas: ImageCanvasDraft['canvas'];
|
||||
generations: ImageCanvasGenerationRecord[];
|
||||
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
|
||||
acknowledgeCandidateLayers?(input: {
|
||||
scope: ImageCanvasHostScope;
|
||||
layerIds: string[];
|
||||
}): Promise<ImageCanvasHostResult<ImageCanvasDraft>>;
|
||||
discardDraft(input: {
|
||||
scope: ImageCanvasHostScope;
|
||||
expectedDraftRevision: number;
|
||||
|
||||
Reference in New Issue
Block a user