完善素材创作画布候选确认与资源画布
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled

新增候选图层独立幂等确认命令与前端 FIFO 屏障

补齐生成候选恢复、JPEG EXIF 尺寸和资源依赖线视口处理

调整资源画布布局同步并更新定向测试、技术文档与排障记忆
This commit is contained in:
2026-08-24 10:51:59 +08:00
parent c01dc006f9
commit 60b1e52d74
16 changed files with 1158 additions and 191 deletions
@@ -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
@@ -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>(
@@ -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}
@@ -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 测试全部通过后,才可宣称图片素材创作正式闭环完成。
+4
View File
@@ -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;