diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index c8fbefec2..cf7d200cd 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -87,6 +87,14 @@
- 验证:对应测试应断言生成按钮点击后 `dialog` 消失但 `image-canvas-editor__generation-frame--generating` 仍然存在。
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx`、`src/components/image-editor/ImageCanvasEditorView.test.tsx`。
+## 图片画布素材多时拖拽卡顿先查等距吸附候选规模
+
+- 现象:画布素材数量增加后,拖拽单个图层或生成占位框时 pointermove 明显卡顿,关闭或绕开吸附后体感恢复。
+- 原因:边缘 / 中心线吸附是线性扫描,但等距吸附如果对所有可吸附素材做两两配对,会在素材数量上来后进入 O(n²) 热路径。
+- 处理:保留边缘 / 中心线全量线性扫描;等距吸附先过滤跨轴相交素材,再只检查轴向邻近候选,不要为远处或不相交素材生成配对候选。
+- 验证:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/ImageCanvasInteractionModel.test.ts`,并在多素材画布拖拽时确认参考线仍能命中邻近图层且 pointermove 不再明显掉帧。
+- 关联:`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/ImageCanvasInteractionModel.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
+
## 图片编辑器宣发素材生成器刷新后不要丢快照
- 现象:图片画布刷新后,宣发素材生成卡片消失,或卡片仍在但游戏名、分类、描述和参考图丢失。
diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
index 03a941f2a..01020cf68 100644
--- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
+++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
@@ -33,6 +33,8 @@
- 右上角提供快捷键入口,点击后打开独立快捷键弹窗;画布不常驻展示说明文案。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` 重命名项目;快捷键只触发对应画布交互,不绕过既有保存 / 生成 / 上传工作流。
- 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
+- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
+- 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。
- 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
- 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。
@@ -43,11 +45,11 @@
- 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。
- 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。
- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
-- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框),BFF 在生成成功后必须直接读取当前项目布局,只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
+- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
-- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。带 `canvasCompletion` 的图片生成以 BFF 返回的 `project` 快照刷新画布;无项目上下文或没有返回快照时,前端才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。
+- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts
index 0aff85bc9..5f3a56dc6 100644
--- a/src/components/image-editor/ImageCanvasEditorModel.test.ts
+++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts
@@ -707,4 +707,59 @@ describe('ImageCanvasEditorModel', () => {
horizontal: 250,
});
});
+
+ it('keeps equal-spacing snap focused on nearby overlapping layers', () => {
+ const movingLayer: CanvasLayer = {
+ id: 'moving',
+ resourceId: 'resource-moving',
+ title: '移动图',
+ src: 'data:image/png;base64,moving',
+ x: 0,
+ y: 0,
+ width: 80,
+ height: 80,
+ originalWidth: 80,
+ originalHeight: 80,
+ zIndex: 1,
+ sourceType: 'uploaded',
+ };
+ const firstLayer: CanvasLayer = {
+ ...movingLayer,
+ id: 'first',
+ resourceId: 'resource-first',
+ x: 100,
+ y: 160,
+ zIndex: 2,
+ };
+ const secondLayer: CanvasLayer = {
+ ...movingLayer,
+ id: 'second',
+ resourceId: 'resource-second',
+ x: 260,
+ y: 160,
+ zIndex: 3,
+ };
+ const offscreenLayers = Array.from({ length: 80 }, (_, index) => ({
+ ...movingLayer,
+ id: `offscreen-${index}`,
+ resourceId: `resource-offscreen-${index}`,
+ x: 2400 + index * 96,
+ y: 1600,
+ zIndex: 10 + index,
+ }));
+
+ const snapped = resolveSnappedLayerPosition(
+ movingLayer,
+ 417,
+ 160,
+ [movingLayer, ...offscreenLayers, firstLayer, secondLayer],
+ 1,
+ );
+
+ expect(snapped.x).toBe(420);
+ expect(snapped.guide).toEqual({
+ vertical: 460,
+ horizontal: 160,
+ });
+ });
});
diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts
index 75a033d9c..33dd17702 100644
--- a/src/components/image-editor/ImageCanvasEditorModel.ts
+++ b/src/components/image-editor/ImageCanvasEditorModel.ts
@@ -37,6 +37,7 @@ export const TOOLBAR_HALF_WIDTH = 132;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const SNAP_THRESHOLD_SCREEN_PX = 18;
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
+const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
export const FIT_VIEW_PADDING = 10;
export const MINIMAP_SIZE = { width: 132, height: 84 };
export const MINIMAP_PADDING = 8;
@@ -1291,10 +1292,19 @@ function findNearestEqualSpacingSnap({
items: CanvasSnapItem[];
threshold: number;
}): SnapCandidate | null {
- const orderedItems = [...items].sort(
- (firstItem, secondItem) =>
- getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
- );
+ const orderedItems = items
+ .filter((item) =>
+ snapItemsOverlapOnCrossAxis(
+ proposedCrossStart,
+ movingCrossSize,
+ item,
+ axis,
+ ),
+ )
+ .sort(
+ (firstItem, secondItem) =>
+ getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
+ );
let nearest: SnapCandidate | null = null;
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
@@ -1304,29 +1314,14 @@ function findNearestEqualSpacingSnap({
}
for (
let secondIndex = firstIndex + 1;
- secondIndex < orderedItems.length;
+ secondIndex < orderedItems.length &&
+ secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
secondIndex += 1
) {
const secondItem = orderedItems[secondIndex];
if (!secondItem) {
continue;
}
- if (
- !snapItemsOverlapOnCrossAxis(
- proposedCrossStart,
- movingCrossSize,
- firstItem,
- axis,
- ) ||
- !snapItemsOverlapOnCrossAxis(
- proposedCrossStart,
- movingCrossSize,
- secondItem,
- axis,
- )
- ) {
- continue;
- }
const firstStart = getSnapItemStart(firstItem, axis);
const firstEnd = getSnapItemEnd(firstItem, axis);
diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx
index 854831a3c..7f7c2b6da 100644
--- a/src/components/image-editor/ImageCanvasWorldView.test.tsx
+++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx
@@ -130,6 +130,23 @@ describe('ImageCanvasWorldView', () => {
expect(screen.queryByRole('status', { name: '' })).toBeNull();
});
+ it('keeps the image layer frame visible while the image is loading', () => {
+ useResolvedAssetReadUrlMock.mockImplementation(() => ({
+ resolvedUrl: '',
+ isResolving: true,
+ shouldResolve: true,
+ }));
+
+ renderWorldView();
+
+ const layerButton = screen.getByRole('button', { name: '选择角色主图' });
+
+ expect(
+ layerButton.querySelector('.image-canvas-editor__layer-image-loading'),
+ ).toBeTruthy();
+ expect(within(layerButton).queryByAltText('画布图片:角色主图')).toBeNull();
+ });
+
it('resolves image layers from objectKey before falling back to src', () => {
useResolvedAssetReadUrlMock.mockImplementation(
(_source: string, options?: { objectKey?: string | null }) => ({
diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx
index 88d4b6d4f..473a82eea 100644
--- a/src/components/image-editor/ImageCanvasWorldView.tsx
+++ b/src/components/image-editor/ImageCanvasWorldView.tsx
@@ -629,17 +629,48 @@ function ImageCanvasImageLayer({
layer: CanvasLayer;
mediaTransform?: string;
}) {
- const { resolvedUrl } = useResolvedAssetReadUrl(layer.src, {
- objectKey: layer.objectKey,
- refreshKey: layer.taskId ?? layer.resourceId,
- });
+ const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
+ layer.src,
+ {
+ objectKey: layer.objectKey,
+ refreshKey: layer.taskId ?? layer.resourceId,
+ },
+ );
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ useEffect(() => {
+ setIsLoaded(false);
+ }, [resolvedUrl]);
+
+ const isLoading =
+ isResolving ||
+ (shouldResolve && !resolvedUrl) ||
+ Boolean(resolvedUrl && !isLoaded);
return (
-
+ <>
+ {isLoading ? (
+
+ ) : null}
+ {resolvedUrl ? (
+
setIsLoaded(true)}
+ onError={() => setIsLoaded(true)}
+ />
+ ) : null}
+ >
);
}
@@ -671,17 +702,23 @@ function ImageCanvasImageSequenceLayer({
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(frames.length > 1);
const currentFrame = frames[Math.min(frameIndex, frames.length - 1)];
+ const hasFrameSource = Boolean(currentFrame?.imageSrc?.trim());
const { resolvedUrl } = useResolvedAssetReadUrl(currentFrame?.imageSrc, {
refreshKey: `${layer.taskId ?? layer.resourceId}:${
currentFrame?.frameIndex ?? frameIndex + 1
}`,
});
+ const [isFrameLoaded, setIsFrameLoaded] = useState(false);
useEffect(() => {
setFrameIndex(0);
setIsPlaying(frames.length > 1);
}, [layer.taskId, layer.resourceId, frames.length]);
+ useEffect(() => {
+ setIsFrameLoaded(false);
+ }, [resolvedUrl]);
+
useEffect(() => {
if (!isPlaying || frames.length <= 1) {
return undefined;
@@ -702,12 +739,27 @@ function ImageCanvasImageSequenceLayer({
return (
<>
-
+ {hasFrameSource && (!resolvedUrl || !isFrameLoaded) ? (
+
+ ) : null}
+ {resolvedUrl ? (
+
setIsFrameLoaded(true)}
+ onError={() => setIsFrameLoaded(true)}
+ />
+ ) : null}