保留并修复不兼容标签图层

前端按媒体族禁用不兼容标签,并在更新入口再次校验。

重载历史非法覆盖时保留图层、回退资源默认类型并显示提示。

只重写命中的布局项并自动保存清理后的覆盖,保留其他历史项。

补充标签矩阵、菜单限制及保存重载回归测试并同步架构决策文档。
This commit is contained in:
2026-08-05 20:34:25 +08:00
parent ca19a3cca3
commit 9690f27930
9 changed files with 317 additions and 29 deletions
@@ -5982,6 +5982,8 @@
- local 状态:`local-*` 只是 ID 形状,不能直接解释为“素材仍在保存”。新上传 / 新生成素材是否 pending 取资源登记在途状态;严格满足兼容谓词的历史自包含本地角色动作序列是持久化终态,不得误报等待。若当前版本尚不能复制这类序列,以准确原因失败关闭;既非 pending 又不满足历史谓词的 unresolved local 图层也失败关闭,但不得承诺稍后一定自动恢复。layout PATCH pending 不参与资源登记判断,系统剪贴板图片导入不受影响。
- schema 与迁移:在现有 `EditorCanvasLayer` 结构体末尾追加 `#[default(None::<String>)] asset_kind_override: Option<String>`,不删除、改名、重排或改类型。legacy 图层类型与资源默认相同则迁移为 `None`,不同则迁移为 override;资源无默认值时只有全部引用图层显式同值才补资源默认,否则保留各自 override;自包含历史序列的显式类型迁入 override,不伪造资源。同步 `migration.rs`、表目录 / 数据契约、生成 bindings、HTTP DTO 与结构化 canonical hash,并运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`。
- 并发边界:未登记图层被禁止复制后,不再按临时资源 ID 合并项目资源创建请求,也不再用一次响应批量改写共享临时 ID。每个合法新增图层保留自己的响应快照与回调;layout PATCH 的串行 latest-wins 队列、共享资源的多布局引用和 session 资源快照按 `resourceId` 去重继续保留,它们与资源创建 single-flight 是不同机制。
- 媒体兼容边界:override 只允许在资源默认类型的同一媒体族内变化。动作、视频各自独立成族,`audio/sound-effect/background-music` 同属音频族,其余类型与空默认值同属图片族。前端菜单禁用跨族标签且更新入口重复校验;后端对每个结构化图层按资源完整校验,跨族值清空后回退资源默认类型,不因该兼容错误拒绝整个保存。
- 历史恢复:客户端读取到已持久化的跨族 override 时必须保留图层、回退资源默认类型、显示明确提示并自动提交清理后的布局;不得再用 `hydrateLayer() -> null -> filter(Boolean)` 静默隐藏持久层仍存在的图层。修复保存只替换命中图层的规范化布局项,其他尚不能 hydrate 的历史项原样保留,避免修复一个标签时顺带删除无关数据。
## 2026-08-03 Agent Runtime 原生工具合同本地失败关闭
@@ -14,7 +14,7 @@
- 中央画布支持背景拖拽平移、滚轮二维平移、`Ctrl / Cmd + 滚轮` 缩放、缩放百分比菜单、显示所有元素和固定比例缩放。
- 画布左下角提供 Lovart 式状态控件:背景色圆点、素材 / 图层入口、小地图开关;小地图显示图层缩略分布和当前视口框,点击小地图执行显示所有元素。
- 画布 chrome 的边框、hover / 选中态、吸附 / 框选参考线、生成类按钮和通用 active 控件使用陶泥儿暖色主题(以 `--platform-accent`、陶土橙主按钮和深棕文字为基准),不得回退为黑色或蓝色主题;元素类型自身的识别色可继续保留。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层的 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件。图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
- 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸。素材类型采用双层语义:`editor_project_resource.asset_kind` 是跨布局共享的资源默认类型,`editor_canvas_layer.asset_kind_override` 是当前布局实例可空的覆盖值,显示与功能判断统一使用 `effectiveAssetKind = assetKindOverride ?? resource.assetKind`;两层都为空时显示 `未知`。点击标签只修改或清除当前图层的 `assetKindOverride`,不能创建新资源、替换 `resourceId` 或修改其他引用同一资源的图层;清除覆盖后立即恢复继承资源默认类型,也不能触发图层选择 / 拖拽事件。标签覆盖必须与资源默认类型属于同一媒体族:`character-animation` 独立为动作族,`video` 独立为视频族,`audio/sound-effect/background-music` 属于音频族,其余类型和空默认值属于图片族;标签菜单禁用跨族选项,更新回调再次校验。加载到历史跨族 override 时不得静默过滤图层,必须清除内存 override、按资源默认类型恢复图层、向用户提示并排队修复保存。图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。
- 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。
- 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。
- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
CANVAS_WORLD_ORIGIN,
@@ -9,6 +9,7 @@ import {
formatCanvasDisplayScalePercent,
hydrateCanvasGenerationDialog,
hydrateLayer,
isCanvasAssetKindOverrideCompatible,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
resolveLayerResourceAssetKind,
@@ -737,6 +738,64 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('falls back from an incompatible persisted override without dropping the layer', () => {
const onAssetKindOverrideFallback = vi.fn();
const hydrated = hydrateLayer(
{
layerId: 'layer-image-with-action-label',
resourceId: 'resource-image',
title: '普通图片',
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
assetKindOverride: 'character-animation',
},
new Map([
['resource-image', { imageSrc: '/read/image.png', assetKind: 'image' }],
]),
{ onAssetKindOverrideFallback },
);
expect(hydrated).toMatchObject({
id: 'layer-image-with-action-label',
src: '/read/image.png',
mediaType: 'image',
resourceAssetKind: 'image',
assetKindOverride: null,
assetKind: 'image',
});
expect(onAssetKindOverrideFallback).toHaveBeenCalledWith({
layerId: 'layer-image-with-action-label',
resourceId: 'resource-image',
resourceAssetKind: 'image',
rejectedAssetKindOverride: 'character-animation',
});
});
it('uses the same media-family compatibility matrix for layer labels', () => {
expect(isCanvasAssetKindOverrideCompatible('image', 'character')).toBe(
true,
);
expect(isCanvasAssetKindOverrideCompatible(null, 'icon')).toBe(true);
expect(
isCanvasAssetKindOverrideCompatible('sound-effect', 'background-music'),
).toBe(true);
expect(isCanvasAssetKindOverrideCompatible('image', 'video')).toBe(false);
expect(
isCanvasAssetKindOverrideCompatible('image', 'character-animation'),
).toBe(false);
expect(isCanvasAssetKindOverrideCompatible('video', 'character')).toBe(
false,
);
expect(isCanvasAssetKindOverrideCompatible('audio', 'icon')).toBe(false);
expect(isCanvasAssetKindOverrideCompatible('video', null)).toBe(true);
});
it('hydrates audio display duration only from resource generation inputs', () => {
const hydrated = hydrateLayer(
{
@@ -729,6 +729,11 @@ export function hydrateCanvasGenerationDialog(
export function hydrateLayer(
snapshot: EditorProjectLayerSnapshot,
resourcesById: Map<string, CanvasLayerResourceMetadata>,
options: {
onAssetKindOverrideFallback?: (
issue: CanvasAssetKindOverrideFallbackIssue,
) => void;
} = {},
): CanvasLayer | null {
const resourceId =
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
@@ -742,12 +747,25 @@ export function hydrateLayer(
resource?.imageSequenceFrames,
);
const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind);
const assetKindOverride = Object.prototype.hasOwnProperty.call(
const hasPersistedAssetKindOverride = Object.prototype.hasOwnProperty.call(
snapshot,
'assetKindOverride',
)
);
const persistedAssetKindOverride = hasPersistedAssetKindOverride
? canvasAssetKindOrNull(snapshot.assetKindOverride)
: canvasAssetKindOrNull(snapshot.assetKind);
const assetKindOverrideFellBack = Boolean(
resource &&
hasPersistedAssetKindOverride &&
persistedAssetKindOverride &&
!isCanvasAssetKindOverrideCompatible(
resourceAssetKind,
persistedAssetKindOverride,
),
);
const assetKindOverride = assetKindOverrideFellBack
? null
: persistedAssetKindOverride;
const assetKind = assetKindOverride ?? resourceAssetKind;
const isSelfContainedLocalResource =
!resource && isSelfContainedLegacyLocalImageSequence(snapshot);
@@ -788,6 +806,14 @@ export function hydrateLayer(
) {
return null;
}
if (assetKindOverrideFellBack && persistedAssetKindOverride) {
options.onAssetKindOverrideFallback?.({
layerId,
resourceId,
resourceAssetKind,
rejectedAssetKindOverride: persistedAssetKindOverride,
});
}
let resourcePersistenceState: CanvasLayer['resourcePersistenceState'];
if (resource) {
resourcePersistenceState = 'registered';
@@ -1215,6 +1241,46 @@ export type CanvasLayerResourceMetadata = {
updatedAt?: string | null;
};
export type CanvasAssetKindOverrideFallbackIssue = {
layerId: string;
resourceId: string;
resourceAssetKind: CanvasAssetKind | null;
rejectedAssetKindOverride: CanvasAssetKind;
};
type CanvasAssetMediaFamily =
'image' | 'character-animation' | 'video' | 'audio';
function resolveCanvasAssetMediaFamily(
assetKind: CanvasAssetKind | null | undefined,
): CanvasAssetMediaFamily {
if (assetKind === 'character-animation') {
return 'character-animation';
}
if (assetKind === 'video') {
return 'video';
}
if (
assetKind === 'audio' ||
assetKind === 'sound-effect' ||
assetKind === 'background-music'
) {
return 'audio';
}
return 'image';
}
export function isCanvasAssetKindOverrideCompatible(
resourceAssetKind: CanvasAssetKind | null | undefined,
assetKindOverride: CanvasAssetKind | null,
) {
return (
assetKindOverride === null ||
resolveCanvasAssetMediaFamily(resourceAssetKind) ===
resolveCanvasAssetMediaFamily(assetKindOverride)
);
}
export function resolveLayerResourceAssetKind(
layer: CanvasLayer,
): CanvasAssetKind | null {
@@ -38,6 +38,7 @@ import {
canvasAssetKindOrNull,
DEFAULT_CANVAS_BACKGROUND_COLOR,
generationInputsOrNull,
isCanvasAssetKindOverrideCompatible,
isInlineEditorMediaSource,
resolveContextMenuPosition,
resolveLayerResourceAssetKind,
@@ -1206,6 +1207,7 @@ export function ImageCanvasEditorView({
const {
projectId,
isProjectReady,
assetKindFallbackNotice,
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
@@ -1423,6 +1425,11 @@ export function ImageCanvasEditorView({
generationSurface.refreshTaskList();
}, [generationSurface]);
const showGenerationWarning = generationSurface.showGenerationWarning;
useEffect(() => {
if (assetKindFallbackNotice) {
showGenerationWarning(assetKindFallbackNotice.message);
}
}, [assetKindFallbackNotice, showGenerationWarning]);
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
if (!projectId || tasks.length === 0) {
@@ -1878,12 +1885,16 @@ export function ImageCanvasEditorView({
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
if (!isCanvasAssetKindOverrideCompatible(resourceAssetKind, assetKind)) {
showGenerationWarning('该标签与素材的媒体类型不兼容,已保留原标签。');
return;
}
captureCanvasHistory({
type: 'change-asset-kind',
count: 1,
layerIds: [layerId],
});
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
const assetKindOverride =
assetKind === resourceAssetKind ? null : assetKind;
const nextLayer = {
@@ -1905,7 +1916,7 @@ export function ImageCanvasEditorView({
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory],
[captureCanvasHistory, showGenerationWarning],
);
const selectAllCanvasObjects = useCallback(() => {
const layerSelectionIds = layersRef.current
@@ -1067,6 +1067,28 @@ describe('ImageCanvasWorldView', () => {
}
});
it('disables labels from other media families', () => {
const imageLayer = createLayer({
resourceAssetKind: 'image',
assetKind: 'character',
});
renderWorldView({ layers: [imageLayer] });
fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' }));
const menu = screen.getByRole('menu', { name: '选择素材标签' });
expect(
within(menu)
.getByRole('menuitem', { name: '图标' })
.hasAttribute('disabled'),
).toBe(false);
for (const label of ['动作', '视频', '音效', '背景音乐']) {
const item = within(menu).getByRole('menuitem', { name: label });
expect(item.hasAttribute('disabled')).toBe(true);
expect(item.title).toBe('该标签与素材的媒体类型不兼容');
}
});
it('hides resolution and duration text for focused audio generation frames', () => {
const dialog = createGenerationDialog({
id: 'dialog-audio',
@@ -31,7 +31,11 @@ import {
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { CANVAS_WORLD_SIZE } from './ImageCanvasEditorModel';
import {
CANVAS_WORLD_SIZE,
isCanvasAssetKindOverrideCompatible,
resolveLayerResourceAssetKind,
} from './ImageCanvasEditorModel';
import type {
CanvasAssetKind,
CanvasGenerationDialogState,
@@ -1125,23 +1129,35 @@ export function ImageCanvasWorldView({
onWheelCapture={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
{CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => (
<PlatformFloatingMenuItem
key={option.value ?? 'unknown'}
className={
option.value === (layer.assetKind ?? null)
? 'image-canvas-editor__kind-menu-item--active'
: undefined
}
onClick={(event) => {
event.stopPropagation();
onUpdateLayerAssetKind(layer.id, option.value);
setOpenLayerKindMenuId(null);
}}
>
{option.label}
</PlatformFloatingMenuItem>
))}
{CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => {
const compatible = isCanvasAssetKindOverrideCompatible(
resolveLayerResourceAssetKind(layer),
option.value,
);
return (
<PlatformFloatingMenuItem
key={option.value ?? 'unknown'}
className={
option.value === (layer.assetKind ?? null)
? 'image-canvas-editor__kind-menu-item--active'
: undefined
}
disabled={!compatible}
title={
compatible
? undefined
: '该标签与素材的媒体类型不兼容'
}
onClick={(event) => {
event.stopPropagation();
onUpdateLayerAssetKind(layer.id, option.value);
setOpenLayerKindMenuId(null);
}}
>
{option.label}
</PlatformFloatingMenuItem>
);
})}
</PlatformFloatingMenu>
</div>
) : null}
@@ -468,6 +468,9 @@ function ProjectPersistenceHarness({
.join(',')}
</span>
<span data-testid="background">{canvasBackgroundColor}</span>
<span data-testid="asset-kind-fallback-notice">
{persistence.assetKindFallbackNotice?.message ?? '-'}
</span>
<span data-testid="flush-completed">{String(flushCompleted)}</span>
<button
type="button"
@@ -822,6 +825,78 @@ describe('useImageCanvasProjectPersistence', () => {
});
});
it('keeps and repairs a layer whose persisted override crosses media families', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '历史坏标签画布',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 7,
layoutStorageVersion: 0,
updatedAt: '2026-08-05T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-image',
resourceId: 'resource-image',
title: '普通图片',
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
assetKindOverride: 'character-animation',
},
],
resources: [
{
resourceId: 'resource-image',
projectId: 'editor-project-default',
imageSrc: '/read/image.png',
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: 'image',
},
],
updatedAt: '2026-08-05T00:00:00.000Z',
});
render(<ProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-image:resource-image:image',
);
});
expect(
screen.getByTestId('asset-kind-fallback-notice').textContent,
).toContain('已保留图层并恢复为素材默认标签');
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
expectedRevision: 7,
layers: expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-image',
resourceId: 'resource-image',
assetKindOverride: null,
}),
]),
}),
);
});
});
it('saves appended layers with the server resource id immediately after resource creation', async () => {
render(<ProjectPersistenceHarness />);
@@ -27,6 +27,7 @@ import {
isInlineEditorMediaSource,
resolveLayerResourceAssetKind,
serializeCanvasLayout,
serializeLayer,
splitCanvasLayoutItems,
viewportToCanvasDisplayViewport,
} from './ImageCanvasEditorModel';
@@ -552,6 +553,11 @@ export function useImageCanvasProjectPersistence({
const coverSnapshotViewportSizeRef = useRef(canvasSize);
const [projectId, setProjectId] = useState<string | null>(null);
const [isProjectReady, setIsProjectReady] = useState(false);
const [assetKindFallbackNotice, setAssetKindFallbackNotice] = useState<{
id: number;
message: string;
} | null>(null);
const assetKindFallbackNoticeIdRef = useRef(0);
const {
setProjectTitle,
setProjectRenameValue,
@@ -1292,9 +1298,32 @@ export function useImageCanvasProjectPersistence({
resourcesById,
currentUserId,
);
const assetKindFallbackLayerIds = new Set<string>();
const hydratedLayers = layerItems
.map((layer) => hydrateLayer(layer, resourcesById))
.map((layer) =>
hydrateLayer(layer, resourcesById, {
onAssetKindOverrideFallback: (issue) => {
assetKindFallbackLayerIds.add(issue.layerId);
},
}),
)
.filter((layer): layer is CanvasLayer => Boolean(layer));
const hydratedLayersById = new Map(
hydratedLayers.map((layer) => [layer.id, layer]),
);
const repairedLayoutItems = assetKindFallbackLayerIds.size
? appliedLayoutItems.map((item) => {
const layerId = canvasLayoutItemId(item);
const hydratedLayer = layerId
? hydratedLayersById.get(layerId)
: undefined;
return layerId &&
assetKindFallbackLayerIds.has(layerId) &&
hydratedLayer
? serializeLayer(hydratedLayer)
: item;
})
: appliedLayoutItems;
setLayerCounter(hydratedLayers.length);
refs.layersRef.current = hydratedLayers;
setLayers(hydratedLayers);
@@ -1332,21 +1361,28 @@ export function useImageCanvasProjectPersistence({
hasAuthoritativeProjectSnapshotRef.current &&
authoritativeProjectIdRef.current === project.projectId;
if (projectIsAuthoritative) {
if (assetKindFallbackLayerIds.size) {
assetKindFallbackNoticeIdRef.current += 1;
setAssetKindFallbackNotice({
id: assetKindFallbackNoticeIdRef.current,
message: `检测到 ${assetKindFallbackLayerIds.size} 个图层的素材标签与媒体类型不兼容,已保留图层并恢复为素材默认标签。`,
});
}
writeEditorProjectSessionCache(
pendingLocalLayout
pendingLocalLayout || assetKindFallbackLayerIds.size
? {
...project,
viewport: appliedViewport,
layers: appliedLayoutItems,
layers: repairedLayoutItems,
}
: project,
currentUserId,
project.canvas?.revision,
);
if (pendingLocalLayout) {
if (pendingLocalLayout || assetKindFallbackLayerIds.size) {
queueProjectLayoutSave(project.projectId, {
viewport: appliedViewport,
layers: appliedLayoutItems,
layers: repairedLayoutItems,
});
}
}
@@ -1550,6 +1586,7 @@ export function useImageCanvasProjectPersistence({
return {
projectId,
isProjectReady,
assetKindFallbackNotice,
projectIdRef,
createProjectResourceForLayer,
appendCanvasLayersWithResources,