diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 0975b8153..39391818d 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -5908,3 +5908,9 @@
- 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。
- 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。
+
+## 2026-08-03 图片画布复制固定为布局实例复用项目资源
+
+- 背景:画布复制逻辑曾为副本生成 `local-resource-copy-*`,但没有创建对应项目资源;自动保存虽然触发,结构化画布会拒绝缺资源引用,legacy 快照刷新时也无法 hydrate 副本。
+- 决策:`layerId` 是同一 canvas 内唯一的布局实例身份,`resourceId` 是允许多个布局实例共享的项目媒体身份。复制、粘贴、创建副本和剪切后粘贴只生成新 `layerId`,复用来源 `resourceId`;已登记资源不得再次上传或创建。临时 `local-*` 资源按用户、项目和临时 ID 合并创建,正式 ID 返回后更新全部共享该临时 ID 的当前图层;session 项目缓存也按 `resourceId` 只保留一份资源快照。
+- 后端边界:现有 `editor_canvas_layer` 已以 `layer_id` 建立行身份并单独保存 `resource_id`,天然支持多 layer 引用同一 resource;本次不修改 HTTP DTO、SpacetimeDB schema、迁移或 bindings,只增加结构化 round-trip 契约测试。
diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
index ef0e0f8ff..cde78649d 100644
--- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
+++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
@@ -57,6 +57,7 @@
- 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。缩放百分比以实际画布 `viewport.scale = 0.5` 作为显示 `100%` 的基准,菜单中的 `50% / 100% / 200%` 分别对应实际 `0.25 / 0.5 / 1`,工程持久化保存和读取仍使用用户可见缩放语义。
- 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。
- 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。Windows 快捷键覆盖 `Ctrl+Z` / `Ctrl+Shift+Z` 撤销与恢复、`Ctrl+A/C/V/X/D` 全选 / 复制 / 粘贴 / 剪切 / 复制一份、`Ctrl+0/1/+/-` 视图控制、`V/H/U/G/Shift+V/M` 工具切换、`Alt+1/Alt+2/Alt+M` 面板切换、`Ctrl+]` / `Ctrl+[` / `Ctrl+Shift+]` / `Ctrl+Shift+[` 层级调整、方向键微移 / `Shift+方向键` 大步移动、`Ctrl+Shift+S` 下载画布素材和 `F2` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。撤销与恢复的内容保护、提示和验收边界统一以 [`图片画布撤销、恢复范围与操作提示方案`](../【图片画布】撤销范围与操作提示方案-2026-07-17.md) 为准。
+- 画布内部复制 / 粘贴和创建副本只新增独立 `layerId` 布局实例,继续引用来源图层的同一项目 `resourceId`;副本可独立移动、排序、分组、显隐和删除,但不得重复上传媒体或为同一图片创建虚假项目资源。没有内部画布剪贴板时,系统剪贴板图片仍按新素材上传。
- 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md
index bb6795e99..f55dd1f7b 100644
--- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md
+++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md
@@ -31,6 +31,8 @@
保存 `layer_id`、`canvas_id`、`project_id`、`owner_user_id`、坐标、宽高、原始尺寸、层级顺序、可选 `group_id`、hidden / locked / flip 状态、`resource_id`、有界的未结构化扩展 JSON、创建与更新时间。查询有 canvas / project 索引;同一 canvas 的层级顺序由 `sort_order` 决定。
+同一 canvas 内 `layer_id` 是唯一的布局实例身份,`resource_id` 是可复用的媒体资源引用,不要求唯一。复制、粘贴或创建副本只创建新的 `layer_id` 和独立几何 / 层级状态,继续引用来源图层的同一 `resource_id`;不得为同一媒体生成虚假的本地资源 ID、重复上传对象或重复创建项目资源。临时 `local-*` 资源尚在登记时,前端按临时 ID 合并资源创建,并在正式 ID 返回后更新全部引用该临时 ID 的布局实例。
+
layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualPrompt / model / provider / taskId / objectKey / assetObjectId / sourceResourceId / assetKind / generationInputs / sourceType` 不得为方便展示而重复进入 `item_json` 或成为 layer 真相;迁移或结构化保存时先逐字段核对 `editor_project_resource`,缺少资源、字段冲突或无法无损重组时必须 fail-closed。历史 layout 若把 `sourceResourceId` 错写成当前图层自己的 `resourceId`,这是无意义的自引用,不作为 A/B 来源冲突:迁移时删除该重复字段并以项目资源表为真相;其他非空且不一致的来源 ID 继续拒绝。唯一存量缺资源例外是历史角色动作产生的自包含本地图层:`resourceId` 必须以 `local-` 开头、`sourceType=generated`、`mediaType=image-sequence`,至少包含一帧;帧序号必须从 1 连续递增,宽高必须是有限正数;所有 `imageSequenceFrames[].imageSrc` 及可选 `thumbnailSrc / previewVideoPath` 都必须是无 query / fragment、无路径回退段的站内根路径,且不能含 `data:`、`blob:`、HTTP 或签名 URL;可选帧 `objectKey` 必须与 `imageSrc` 去掉首斜杠后完全一致,图层级 `imageSrc / objectKey / assetObjectId` 必须为空,图层级 `src` 只允许为空或与首帧 `imageSrc` 完全一致。满足这些条件但资源行已不存在时,保留其有界媒体扩展和生成元数据以便前端从首帧恢复,不把这些字段从 canonical hash 中剥离;`sourceResourceId` 目标仍存在时必须属于同工程和 owner,目标已删除时保留原引用参与 hash,不据此伪造资源行。该例外不适用于普通图片、视频、音频、非本地 ID、空帧序列或已有资源字段冲突。存量 `assetKind / generationInputs` 仅允许在资源行尚未记录时由 apply 事务补入资源表,dry-run 只计算预览而不写库。前端读取项目快照时继续使用随项目返回的 resources 按 `resource_id` hydrate,兼容现有画布快照语义。
### 3.2 `editor_canvas_generation_dialog`
diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs
index 5df4afb18..2bc6ad74b 100644
--- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs
+++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs
@@ -9691,6 +9691,36 @@ mod tests {
);
}
+ #[test]
+ fn structured_canvas_allows_distinct_layers_to_share_one_resource() {
+ let shared = r#"[
+ {"layerId":"layer-1","resourceId":"resource-shared","x":10},
+ {"layerId":"layer-2","resourceId":"resource-shared","x":42}
+ ]"#;
+ let layout = parse_structured_canvas_layout(shared)
+ .expect("distinct layout instances may share a resource");
+
+ assert_eq!(layout.layers.len(), 2);
+ assert_eq!(layout.layers[0].resource_id, "resource-shared");
+ assert_eq!(layout.layers[1].resource_id, "resource-shared");
+
+ let rebuilt = serialize_structured_canvas_layout(&layout)
+ .expect("shared-resource layout should serialize");
+ let rebuilt_value: JsonValue =
+ serde_json::from_str(&rebuilt).expect("rebuilt layout should be valid JSON");
+ assert_eq!(rebuilt_value.as_array().map(Vec::len), Some(2));
+
+ let shared_integrity = canvas_layout_integrity(shared).expect("shared integrity");
+ let single_integrity =
+ canvas_layout_integrity(r#"[{"layerId":"layer-1","resourceId":"resource-shared"}]"#)
+ .expect("single integrity");
+ assert_eq!(shared_integrity.layer_count, 2);
+ assert_eq!(
+ shared_integrity.resource_refs_sha256,
+ single_integrity.resource_refs_sha256
+ );
+ }
+
#[test]
fn structured_canvas_rollback_rejects_layout_over_two_mib() {
let oversized = "x".repeat(EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES + 1);
diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx
index 27e840204..4241e5ef5 100644
--- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx
+++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx
@@ -17,6 +17,7 @@ import {
createAuthValue,
createDataTransferStub,
createDeferred,
+ defaultEditorProjectResources,
dispatchPointerEvent,
ImageCanvasEditorView,
setupImageCanvasEditorViewTestLifecycle,
@@ -1287,6 +1288,64 @@ describe('ImageCanvasEditorView asset library integration', () => {
);
});
+ it('restores a copied layout that references the same project resource after re-entering', async () => {
+ const firstRender = render();
+ await waitFor(() => {
+ expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
+ });
+ saveEditorProjectLayoutMock.mockClear();
+ createEditorProjectResourceMock.mockClear();
+
+ fireEvent.contextMenu(
+ screen.getByAltText('画布图片:拼图素材').closest('button')!,
+ {
+ clientX: 510,
+ clientY: 330,
+ },
+ );
+ fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
+ fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
+ clientX: 360,
+ clientY: 240,
+ });
+ fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
+
+ await waitFor(() => {
+ expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
+ });
+ const savedLayers = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]
+ ?.layers as Array<{ layerId: string; resourceId: string }>;
+ const copiedPuzzleLayouts = savedLayers.filter(
+ (layer) => layer.resourceId === 'resource-puzzle',
+ );
+ expect(copiedPuzzleLayouts).toHaveLength(2);
+ expect(
+ new Set(copiedPuzzleLayouts.map((layer) => layer.layerId)).size,
+ ).toBe(2);
+ expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
+
+ firstRender.unmount();
+ window.sessionStorage.clear();
+ loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
+ withEditorProjectCanvasRevision(
+ {
+ projectId: 'editor-project-default',
+ title: '默认项目',
+ viewport: { x: 0, y: 0, scale: 1 },
+ layers: savedLayers,
+ resources: defaultEditorProjectResources,
+ updatedAt: '2026-08-03T00:00:00.000Z',
+ },
+ 1,
+ ),
+ );
+
+ render();
+ await waitFor(() => {
+ expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
+ });
+ });
+
it('drops files into the asset panel only once without creating canvas layers', async () => {
render();
diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx
index e6f8a7236..eff2653ef 100644
--- a/src/components/image-editor/ImageCanvasEditorView.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.tsx
@@ -1793,6 +1793,7 @@ export function ImageCanvasEditorView({
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
+ appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts
index 88b353c96..fb56d8119 100644
--- a/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts
+++ b/src/components/image-editor/ImageCanvasLayerCommandModel.test.ts
@@ -81,7 +81,7 @@ describe('ImageCanvasLayerCommandModel', () => {
expect(duplicated).toMatchObject([
{
id: 'layer-copy-test-0',
- resourceId: 'local-resource-copy-test-0',
+ resourceId: 'resource-first',
title: '第一张 副本',
x: 300,
y: 200,
@@ -90,7 +90,7 @@ describe('ImageCanvasLayerCommandModel', () => {
},
{
id: 'layer-copy-test-1',
- resourceId: 'local-resource-copy-test-1',
+ resourceId: 'resource-second',
title: '第二张 副本',
x: 350,
y: 260,
@@ -107,6 +107,7 @@ describe('ImageCanvasLayerCommandModel', () => {
});
expect(cutPaste[0]?.title).toBe('第一张');
expect(cutPaste[0]?.x).toBe(first.x + 32);
+ expect(cutPaste[0]?.resourceId).toBe(first.resourceId);
});
it('creates a cloned clipboard and removes target layers', () => {
diff --git a/src/components/image-editor/ImageCanvasLayerCommandModel.ts b/src/components/image-editor/ImageCanvasLayerCommandModel.ts
index 24079124e..16d18bb0a 100644
--- a/src/components/image-editor/ImageCanvasLayerCommandModel.ts
+++ b/src/components/image-editor/ImageCanvasLayerCommandModel.ts
@@ -76,7 +76,6 @@ export function duplicateCanvasLayers({
return sourceLayers.map((layer, index) => ({
...cloneLayer(layer),
id: `layer-copy-${stamp}-${index}`,
- resourceId: `local-resource-copy-${stamp}-${index}`,
title: renameCopies ? `${layer.title} 副本` : layer.title,
x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32,
y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32,
diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
index 3f063c6aa..795bd699a 100644
--- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
+++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx
@@ -140,6 +140,8 @@ function LayerCommandsHarness({
selectedLayerIds,
canvasGenerationDialogs,
setLayers,
+ appendCanvasLayersWithResources: (nextLayers) =>
+ setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
@@ -492,6 +494,11 @@ describe('useImageCanvasLayerCommands', () => {
expect(screen.getByTestId('selection').textContent).toContain(
'layer-copy-',
);
+ fireEvent.click(screen.getByRole('button', { name: '创建选中副本' }));
+ const layerIds = (screen.getByTestId('layers').textContent ?? '')
+ .split('|')
+ .map((entry) => entry.split(':')[0]);
+ expect(new Set(layerIds).size).toBe(layerIds.length);
fireEvent.click(screen.getByRole('button', { name: '只选第三层' }));
fireEvent.click(screen.getByRole('button', { name: '选中置顶' }));
diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts
index 4aa6ecc98..6be4188c5 100644
--- a/src/components/image-editor/useImageCanvasLayerCommands.ts
+++ b/src/components/image-editor/useImageCanvasLayerCommands.ts
@@ -43,6 +43,7 @@ type LayerCommandsOptions = {
selectedLayerIds: string[];
canvasGenerationDialogs?: CanvasGenerationDialogState[];
setLayers: Dispatch>;
+ appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void;
setSelectedLayerId: Dispatch>;
setSelectedLayerIds: Dispatch>;
setHoveredLayerId: Dispatch>;
@@ -132,6 +133,7 @@ export function useImageCanvasLayerCommands({
selectedLayerIds,
canvasGenerationDialogs = [],
setLayers,
+ appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
setHoveredLayerId,
@@ -148,6 +150,7 @@ export function useImageCanvasLayerCommands({
const [canvasClipboard, setCanvasClipboard] =
useState(null);
const canvasClipboardRef = useRef(null);
+ const duplicateLayerSequenceRef = useRef(0);
const closeContextMenus = useCallback(() => {
setContextMenu(null);
setImageContextMenu(null);
@@ -172,13 +175,16 @@ export function useImageCanvasLayerCommands({
sourceLayers: CanvasLayer[],
canvasPoint?: { x: number; y: number },
options: { renameCopies?: boolean } = {},
- ) =>
- duplicateCanvasLayers({
+ ) => {
+ duplicateLayerSequenceRef.current += 1;
+ return duplicateCanvasLayers({
sourceLayers,
allLayers: layers,
canvasPoint,
renameCopies: options.renameCopies !== false,
- }),
+ stamp: `${Date.now()}-${duplicateLayerSequenceRef.current}`,
+ });
+ },
[layers],
);
@@ -195,7 +201,7 @@ export function useImageCanvasLayerCommands({
return false;
}
captureCanvasHistory({ type: 'paste-image', count: nextLayers.length });
- setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
+ appendCanvasLayersWithResources(nextLayers);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
setActiveTool('select');
@@ -206,8 +212,8 @@ export function useImageCanvasLayerCommands({
captureCanvasHistory,
closeContextMenus,
duplicateLayersToPoint,
+ appendCanvasLayersWithResources,
setActiveTool,
- setLayers,
setSelectedLayerId,
setSelectedLayerIds,
],
@@ -309,7 +315,7 @@ export function useImageCanvasLayerCommands({
return;
}
captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length });
- setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
+ appendCanvasLayersWithResources(nextLayers);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
closeContextMenus();
@@ -319,7 +325,7 @@ export function useImageCanvasLayerCommands({
duplicateLayersToPoint,
getContextTargetLayerIds,
layers,
- setLayers,
+ appendCanvasLayersWithResources,
setSelectedLayerId,
setSelectedLayerIds,
]);
@@ -332,7 +338,7 @@ export function useImageCanvasLayerCommands({
return;
}
captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length });
- setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
+ appendCanvasLayersWithResources(nextLayers);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
setContextMenu(null);
@@ -342,9 +348,9 @@ export function useImageCanvasLayerCommands({
duplicateLayersToPoint,
getSelectedTargetLayerIds,
layers,
+ appendCanvasLayersWithResources,
setContextMenu,
setImageContextMenu,
- setLayers,
setSelectedLayerId,
setSelectedLayerIds,
]);
diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
index 03f7d4439..af99d9ece 100644
--- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
+++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
@@ -264,6 +264,30 @@ function ProjectPersistenceHarness({
>
append
+