From 7ce4a3a9c63358697af37cbd381a925f4f7d8c17 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 17:07:15 +0800 Subject: [PATCH 01/21] =?UTF-8?q?=E6=8A=8A=E7=B4=A0=E6=9D=90=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E4=BB=8E=E3=80=8C=E7=BC=96=E8=BE=91=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E3=80=8D=E9=9D=A2=E6=9D=BF=E6=8B=86=E6=88=90?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ResourceTypePanel(src/view/project-development/ResourceTypePanel.tsx):类型选一项即落盘,category 传用户选中值、tags 逐字回传落盘原值,保存在飞时沿用 closeOnBackdrop/closeOnEscape = !saving - 新增 resourceAssetDisplayName.ts:两块面板共用同一个素材名副标题口径(localPath basename),不再各写一份 - 新增 resourceTypePanel.css:类型面板的弹窗骨架与信息浮层「分类」行入口样式,按 AGC 内独立文件约定,不动 packages/** 与 styles.css - ResourceClassificationPanel 删掉类型 chip 与 categoryChoice:保存时恒回传 gameCreationAppAssetPersistedCategory(asset),「只改标签不动分类」由结构保证 - index.tsx 新增 resourceTypeAssetId 与 resourceTypeAsset;工具条在「编辑标签」旁新增「素材类型」按钮(Shapes 图标) - index.tsx 用合并后的 resourceClassificationOverlayOpen 纳入点外部/Esc 浮层遮挡判据,并同步 useEffect 依赖项 - index.tsx 渲染新面板:保存成功后由宿主收起面板并沿用 manifest 重载路径,让卡片立刻落到新栏目 - 信息浮层「分类」行接出第二入口:按钮渲染在 dd 之外,不污染 dt/dd 字段读取口径;非 manifest 资产不给入口 - resourceCanvasInfoModel 导出 RESOURCE_INFO_CATEGORY_FIELD_LABEL,模型与入口共用同一行标识 - 新增 tests/resourceTypePanel.test.tsx:选中即落盘、tags 逐字回传、没碰过不写盘、显式待归类、失败回退、saving 锁、Esc/遮罩、面板骨架共 11 条 - 迁移「没碰过分类就回传落盘原值」对照用例并保留两条(标签面板侧),chip 用例搬到新面板文件 - 集成用例改走新入口:选中即落盘并自动关窗,另补 Esc/点外部不串台、只改标签后 manifest category 逐字不变两条宿主级用例 - appSurface 工具条清单补上「素材类型」 --- .../project-workspace/resourceTypePanel.css | 69 +++ .../resourceCanvasInfoModel.ts | 13 +- .../ResourceClassificationPanel.tsx | 72 +-- .../ResourceInfoPanelView.tsx | 29 +- .../project-development/ResourceTypePanel.tsx | 181 ++++++++ .../src/view/project-development/index.tsx | 86 +++- .../resourceAssetDisplayName.ts | 12 + .../appSurface/project-development.suite.ts | 8 +- .../projectResourceLiveIntegration.test.tsx | 160 ++++++- .../resourceClassificationPanel.test.tsx | 186 ++------ .../tests/resourceTypePanel.test.tsx | 422 ++++++++++++++++++ 11 files changed, 1011 insertions(+), 227 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css create mode 100644 apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx create mode 100644 apps/ai-game-creator-shell/src/view/project-development/resourceAssetDisplayName.ts create mode 100644 apps/ai-game-creator-shell/tests/resourceTypePanel.test.tsx diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css b/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css new file mode 100644 index 000000000..f582f3885 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css @@ -0,0 +1,69 @@ +/* + * 「设置素材类型」面板的弹窗骨架与信息浮层的类型入口。 + * + * 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 —— + * 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动 + * 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。 + * + * 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、 + * 中间一行可压缩可滚动、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。 + */ +.game-resource-type-dialog { + width: min(480px, 100%); + max-height: min(720px, calc(100dvh - 40px)); + grid-template-rows: auto minmax(0, 1fr); +} + +/* + * 滚动落在 body 这一行:`min-height: 0` 是网格项能被 `1fr` 压缩的前提, + * 否则内容高度会顶回轨道、`overflow-y` 永远不触发。 + */ +.game-resource-type-body { + display: grid; + gap: 10px; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +/* + * 类型选项之上的一句短提示。只说这一屏要选什么,不写规则说明或开发解释。 + */ +.game-resource-type-hint { + margin: 0; + color: var(--platform-text-base); + font-size: 12px; +} + +.game-resource-type-error { + margin: 0; + color: #b3261e; + font-size: 11px; +} + +/* + * 第二入口:信息浮层「分类」行右侧的入口按钮。 + * + * 放在 `dd` **外面**:信息字段的读取口径(`dt` / `dd` 文本逐行比对)在两处共用, + * 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。 + */ +.game-resource-info-field-action { + align-self: start; + margin-left: auto; + padding: 0 6px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 8px; + background: transparent; + color: var(--platform-text-base); + font-size: 11px; + line-height: 20px; + cursor: pointer; +} + +.game-resource-info-field-action:hover, +.game-resource-info-field-action:focus-visible { + border-color: var(--platform-surface-hover-border); + background: var(--platform-warm-bg); + color: var(--platform-text-strong); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasInfoModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasInfoModel.ts index 9370975b7..09b4ce29a 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasInfoModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasInfoModel.ts @@ -19,6 +19,14 @@ export type ResourceInfoFieldRow = { const EMPTY_TAGS_TEXT = '暂无标签'; +/** + * 「分类」字段的行标识。 + * + * 画布上的信息浮层用这一行接出素材类型设置入口(运行页签的「信息展示」不带入口, + * 同一份字段清单在两处渲染);把字面量放在这里,模型与入口两侧不会各写一个。 + */ +export const RESOURCE_INFO_CATEGORY_FIELD_LABEL = '分类'; + /** * 栏目文案与资源筛选同源;`version` 不是跨端资源分类,和画布栏目一样单独给文案。 */ @@ -43,7 +51,10 @@ export function resolveResourceInfoFieldRows( { label: '名称', value: resource.label }, { label: '路径', value: resource.path }, { label: '类型', value: resource.mediaType }, - { label: '分类', value: resourceCategoryLabel(resource.category) }, + { + label: RESOURCE_INFO_CATEGORY_FIELD_LABEL, + value: resourceCategoryLabel(resource.category), + }, { label: '标签', value: tags.length > 0 ? tags.join('、') : EMPTY_TAGS_TEXT, diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx index 0f209158c..1bd9ed092 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx @@ -5,38 +5,22 @@ import { useState } from 'react'; import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; import { PlatformPillBadge } from '../../../../../packages/shared/src/components/PlatformPillBadge'; -import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; import { - GAME_CREATION_APP_ASSET_CATEGORIES, - type GameCreationAppAssetCategory, - gameCreationAppAssetCategory, type GameCreationAppAssetManifestEntry, gameCreationAppAssetPersistedCategory, gameCreationAppAssetTags, normalizeGameCreationAppAssetTags, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { ThemedModal } from '../../components/modal/ThemedModal'; -import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences'; import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage'; +import { resourceAssetDisplayName } from './resourceAssetDisplayName'; type UpdateLocalProjectResourceClassificationResult = { asset: GameCreationAppAssetManifestEntry; committedProjectRevision: number; }; -/** - * 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。 - * - * 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取, - * 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。 - */ -const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS = - GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({ - id: category, - label: resourceReferenceCategoryLabel(category), - })); - /** * 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。 * 与输入框旧的"整段逗号分隔文本"口径完全一致,改动只是把结果换成逐个可删的 pill。 @@ -57,16 +41,6 @@ function mergeResourceClassificationTagDraft( return next; } -/** - * 素材名取 `localPath` 的 basename:manifest 资产没有独立的显示名字段, - * 与资源卡、`@` 面板的显示口径一致。 - */ -function resourceAssetDisplayName(localPath: string) { - const normalized = localPath.replaceAll('\\', '/'); - const segments = normalized.split('/'); - return segments[segments.length - 1] || localPath; -} - function resourceClassificationErrorMessage(error: unknown) { // 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出; // 与重命名、删除共用同一份映射。 @@ -89,26 +63,17 @@ export function ResourceClassificationPanel({ onSaved, }: ResourceClassificationPanelProps) { /** - * 素材类型(功能分类)从本面板设置,与标签同一次保存、同一条写入路径。 + * 本面板只编辑标签:素材类型(功能分类)在「设置素材类型」面板里单独设置。 * - * **选择器读的是「显示口径」** `gameCreationAppAssetCategory`:它与资源画布栏目 - * (`projectResourceAssetCategory` / `projectResourceCanvasCategory`)同一份读数, - * 所以用户看到的选中项恰好就是他看到的那一栏,不存在「面板说 A、卡片在 B 栏」。 - * - * **写回不能用这个读数**:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind` 能派生出 - * 明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类 + * **写回必须用落盘口径** `gameCreationAppAssetPersistedCategory`,不能用读显示口径 + * `gameCreationAppAssetCategory`:显示口径含读时自愈 —— 落盘 `unclassified` 而 `kind` + * 能派生出明确分类时,读出来的是派生值。回传它就等于用户只改标签也被静默改了分类 * (真机上同一条 `kind:"ui"` 资产同时出现过 `unclassified` 与 `ui-interaction` 两种落盘值)。 * - * 因此用 `categoryChoice` 表达「用户是否主动选过」: - * - `null`(没碰过分类控件)→ 回传 `gameCreationAppAssetPersistedCategory` 的落盘原值; - * - 用户选过 → 回传用户选的那个值。 - * 这条分叉是本次改动的核心不变量,两个方向都由 - * `tests/resourceClassificationPanel.test.tsx` 的对照用例钉住。 + * 拆出类型面板后这条不变量不再依赖"用户是否碰过控件",而是结构性的: + * 本面板没有类型控件,`category` 恒为落盘原值。 + * `tests/resourceClassificationPanel.test.tsx` 两个方向各有用例钉住它。 */ - const [categoryChoice, setCategoryChoice] = - useState(null); - const displayedCategory = - categoryChoice ?? gameCreationAppAssetCategory(asset); const [tags, setTags] = useState(() => gameCreationAppAssetTags(asset), ); @@ -159,9 +124,8 @@ export function ResourceClassificationPanel({ expectedProjectId: projectId, expectedProjectRevision: status.revision, assetId: asset.id, - // 用户没主动选类型就原样回传落盘值(不含读时自愈),选了就写用户选的那个。 - category: - categoryChoice ?? gameCreationAppAssetPersistedCategory(asset), + // 分类不由本面板编辑:恒回传落盘原值(不含读时自愈)。 + category: gameCreationAppAssetPersistedCategory(asset), tags: normalizeGameCreationAppAssetTags(tagsToSave), }, }, @@ -214,20 +178,10 @@ export function ResourceClassificationPanel({
{/* - 素材类型选择器:选中的那一项就是这张卡当前所在的画布栏目。 - 点任意一项即视为用户主动改类型(即便点的是当前已选中的那一项), - 与「没碰过就回传落盘原值」的分叉保持同一条判据,不做隐式 no-op。 + 本面板没有素材类型控件:类型是「设置素材类型」面板的编辑对象,入口在资源卡选中 + 工具条上。曾长在这里的类型 chip 只改本地 state、不落盘,保存又只能借道标签的 + 「添加」,导致"改了类型没生效"。 */} - {tags.length > 0 ? (
    {tags.map((tag) => ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceInfoPanelView.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceInfoPanelView.tsx index 94eadd92e..6134941e9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceInfoPanelView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceInfoPanelView.tsx @@ -3,6 +3,7 @@ import { Info, X } from 'lucide-react'; import { resolveResourceInfoFieldRows, resolveResourceInfoPanelStyle, + RESOURCE_INFO_CATEGORY_FIELD_LABEL, type ResourceInfoPanelAnchor, } from '../../features/resource-canvas/resourceCanvasInfoModel'; import type { ProjectResource } from './resourceProjectionModel'; @@ -10,11 +11,17 @@ import type { ProjectResource } from './resourceProjectionModel'; /** * 只读资源信息字段。运行页签的「信息展示」与画布上的信息浮层共用这一份, * 字段清单只在 `resolveResourceInfoFieldRows` 里定义,两处不会各说一套。 + * + * `onEditCategory` 是「分类」行的可选入口(只有画布浮层传):分类值本身仍然只读展示, + * 入口按钮渲染在 `dd` **外面** —— 字段值的读取口径是 `dt` / `dd` 的文本, + * 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。 */ export function ResourceInfoFieldsView({ resource, + onEditCategory, }: { resource: ProjectResource; + onEditCategory?: () => void; }) { return (
    @@ -22,6 +29,18 @@ export function ResourceInfoFieldsView({
    {row.label}
    {row.value}
    + {onEditCategory && + row.label === RESOURCE_INFO_CATEGORY_FIELD_LABEL ? ( + + ) : null}
    ))}
    @@ -30,6 +49,10 @@ export function ResourceInfoFieldsView({ export type ResourceInfoPanelViewProps = ResourceInfoPanelAnchor & { resource: ProjectResource; + /** + * 「分类」行的类型设置入口;不传就没有入口(资源不是 manifest 资产时宿主不传)。 + */ + onEditCategory?: () => void; onClose: () => void; }; @@ -44,6 +67,7 @@ export function ResourceInfoPanelView({ sourceLayer, viewport, canvasSize, + onEditCategory, onClose, }: ResourceInfoPanelViewProps) { const style = resolveResourceInfoPanelStyle({ @@ -75,7 +99,10 @@ export function ResourceInfoPanelView({
) : null} diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 2a2444f4b..0a7a3c789 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -67,6 +67,15 @@ type ResourceLayoutWriteIntent = { scopeEpoch: number; resourceSignature: string; conflictRetries: number; + /** + * 显式「整理画布」写意图:这一笔写回按 `rederive` 策略丢掉全部自动坐标重算。 + * + * 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生 + * 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能 + * 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`, + * 只补新卡、不动既有坐标。 + */ + rederive: boolean; }; type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent; @@ -74,9 +83,10 @@ type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent; const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2; /** - * 自动坐标策略。`rederive` 在每次协调时丢弃全部自动坐标并按当前资源与拓扑重算, - * PRD 要求的「关系图首次就绪 / `dependencyDepth` / 拓扑身份签名变化后按最终拓扑 - * 重算」依赖它;`preserve` 只补新资源 ID,不重排任何已存在的坐标。 + * 自动坐标策略。`rederive` 丢掉全部自动坐标、按当前资源与拓扑重算(手动坐标原样保留); + * 它现在只由显式动作触发——用户的「整理画布」按钮,以及「关系图首次就绪」那一次 + * `rederiveNow()`。`preserve` 只补新资源 ID,不重排任何已存在的坐标,是画布默认口径: + * 新增一张素材不再牵动整张画布。 */ type AutomaticPositionPolicy = 'rederive' | 'preserve'; @@ -532,6 +542,7 @@ export function useProjectResourceCanvasLayout({ scopeEpoch, resourceSignature: signature, conflictRetries, + rederive: false, }); } if (mountedRef.current) { @@ -565,7 +576,9 @@ export function useProjectResourceCanvasLayout({ const writePolicy = intent.kind === 'manual' ? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY - : automaticPositionPolicy(rederiveAutomaticPositions); + : intent.rederive + ? 'rederive' + : automaticPositionPolicy(rederiveAutomaticPositions); const reconciled = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, @@ -686,6 +699,9 @@ export function useProjectResourceCanvasLayout({ if (intent.kind === 'manual') { redragRequiredScopeEpochRef.current = null; setNotice('布局已保存'); + } else if (intent.rederive) { + // 显式整理复用同一条提示:用户按了按钮,就必须看到"这次重算真的落盘了"。 + setNotice('布局已保存'); } if (needsResourceSync) { enqueueResourceSyncRef.current(currentScope.epoch); @@ -1004,6 +1020,48 @@ export function useProjectResourceCanvasLayout({ [applyLayout, initializationReady, scopeKey], ); + /** + * 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回 + * sidecar(沿用既有 `changed` 门),画布本身先乐观按重算结果显示。 + * + * 这是整张画布重排的唯一入口——画布不再因为资源协调签名变化自动重派生。走的是与自动 + * 同步同一条写队列与写回链路,只把策略换成 `rederive`;用户可见反馈仍由既有的 + * `notice` / `saving` 状态位承担,不另造一套状态。 + */ + const rederiveNow = useCallback(() => { + const scope = scopeRef.current; + if ( + scope.key !== scopeKey || + !initializationReady || + initializedScopeEpochRef.current !== scope.epoch + ) { + return; + } + const queued = writeQueueRef.current.find( + (intent): intent is ResourceLayoutWriteIntent => + intent.kind === 'resources' && + intent.scopeEpoch === scope.epoch && + intent.rederive && + intent !== activeWriteIntentRef.current, + ); + if (queued) { + queued.resourceSignature = resourceSignatureRef.current; + queued.conflictRetries = 0; + } else { + writeQueueRef.current.push({ + kind: 'resources', + scopeEpoch: scope.epoch, + resourceSignature: resourceSignatureRef.current, + conflictRetries: 0, + rederive: true, + }); + } + // 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。 + rebuildOptimisticLayout(scope.epoch, 'rederive'); + setSaving(true); + pumpWritesRef.current(); + }, [initializationReady, rebuildOptimisticLayout, scopeKey]); + const scopeMatches = initializationReady && scopeRef.current.key === scopeKey && @@ -1035,5 +1093,6 @@ export function useProjectResourceCanvasLayout({ readReport, scopeIdentity: scopeKey, commitPosition, + rederiveNow, }; } diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx new file mode 100644 index 000000000..40271eea6 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -0,0 +1,838 @@ +/** @vitest-environment jsdom */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, + ProjectResourceCanvasCategory, + ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; +import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel'; +import { + createResourceSignature, + useProjectResourceCanvasLayout, +} from '../src/view/project-development/useProjectResourceCanvasLayout'; +import { + createGameCreationAppManifest, + findResourceSelectButton, + fireEvent, + openResourceFilterPanel, + React, + render, + screen, +} from './appSurface/harness'; + +/** + * 「AGC 资源画布:新素材不自动重排 + 生成后自动聚焦 + 显式整理画布」的行为级验收。 + * + * 三条口径: + * 1. 画布不再因为「资源协调签名变化」重排整张画布——新增一张素材只补它的位置, + * 既有自动卡坐标逐值不变;要重排只能由用户按「整理画布」显式发起。 + * 2. 新素材入库后自动进入视口并被选中;被搜索条件挡住时沿用既有「清除搜索并定位」。 + * 3. 首次打开项目 / 切项目不触发聚焦跳转。 + */ + +const NEW_ASSET_ID = 'asset-newly-generated'; +const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; + +type AssetFixture = GameCreationAppAssetManifestEntry; + +function pngAsset(id: string, fileName: string): AssetFixture { + return { + id, + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: `assets/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function markdownAsset(id: string, fileName: string): AssetFixture { + return { + id, + kind: 'game-rules', + mediaType: 'text/markdown', + localPath: `docs/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function manifestFor( + projectId: string, + assets: AssetFixture[], +): GameCreationAppManifest { + return { + ...createGameCreationAppManifest(projectId, `${projectId} 项目`), + assets: assets.map((asset) => structuredClone(asset)), + }; +} + +type LayoutWrite = { + projectPath: string; + mode: string; + positions: ProjectResourceCanvasPosition[]; +}; + +type FakeTauri = { + invoke: ReturnType; + layoutWrites: LayoutWrite[]; + layoutReads: Array<{ projectPath: string; mode: string }>; + unexpectedCommands: string[]; +}; + +function resourceGraphFor( + resources: Array<{ resourceId: string }> | undefined, +) { + const resourceIds = (resources ?? []).map((resource) => resource.resourceId); + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +/** + * 只铺张画布真正用到的那几个本地命令。 + * + * 未知命令返回 `undefined` 并记账(而不是抛错):这份用例验的是布局与聚焦链路, + * 不该因为画布里别的入口多调一个命令就整体转红;真要漏了关键命令, + * 目标链路自己会停在"等待"上,断言照样失败。 + */ +function installLayoutTauri( + options: { + projectIdsByPath?: Record; + layoutByScope?: Record; + } = {}, +): FakeTauri { + const layoutWrites: LayoutWrite[] = []; + const layoutReads: Array<{ projectPath: string; mode: string }> = []; + const unexpectedCommands: string[] = []; + const persisted = new Map(); + const revisions = new Map(); + for (const [key, positions] of Object.entries(options.layoutByScope ?? {})) { + persisted.set(key, structuredClone(positions)); + } + + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: 1 }; + } + if (command === 'read_local_project_resource_graph') { + return resourceGraphFor( + args?.resources as Array<{ resourceId: string }> | undefined, + ); + } + if (command === 'read_local_project_resource_canvas_layout') { + const projectPath = String(args?.projectPath ?? ''); + const mode = String(args?.mode ?? ''); + layoutReads.push({ projectPath, mode }); + const key = `${projectPath}|${mode}`; + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: options.projectIdsByPath?.[projectPath] ?? '', + mode, + revision: revisions.get(key) ?? 0, + positions: structuredClone(persisted.get(key) ?? []), + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + const projectPath = String(args?.projectPath ?? ''); + const mode = String(args?.mode ?? ''); + const key = `${projectPath}|${mode}`; + const positions = structuredClone( + (args?.positions ?? []) as ProjectResourceCanvasPosition[], + ); + persisted.set(key, positions); + revisions.set(key, Number(args?.expectedRevision ?? 0) + 1); + layoutWrites.push({ projectPath, mode, positions }); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: String(args?.expectedProjectId ?? ''), + mode, + revision: revisions.get(key)!, + positions, + updatedAt: 1, + }, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 8, + content: '# 玩法规则', + }; + } + unexpectedCommands.push(command); + return undefined; + }, + ); + + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return { invoke, layoutWrites, layoutReads, unexpectedCommands }; +} + +function typeWrites(tauri: FakeTauri) { + return tauri.layoutWrites.filter((write) => write.mode === 'type'); +} + +function dependencyWrites(tauri: FakeTauri) { + return tauri.layoutWrites.filter((write) => write.mode === 'dependency'); +} + +function selectedResourceIdsInDom() { + return Array.from( + document.querySelectorAll('[data-resource-id]'), + ) + .filter((element) => element.getAttribute('aria-pressed') === 'true') + .map((element) => element.dataset.resourceId); +} + +/** + * 把布局读写与聚焦裁决链跑到底再下断言。 + * + * 「不该聚焦」这类否定断言最怕"跑得太早":断言时链路还没走到聚焦那一步,写什么都会绿。 + * 这里先把微任务与一个宏任务放完,让链路在该触发的情况下已经触发过。 + */ +async function settleFocusChain() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +type ProjectFixture = { + projectId: string; + projectPath: string; + assets: AssetFixture[]; +}; + +/** + * 把 manifest 交给真实工作台视图持有,并提供两个"外部世界"动作: + * 「入库新素材」(等价于生成流程落盘后 `onManifestChange` 收到多一条 asset 的清单) + * 与「切换项目」(换 projectPath + projectId + 清单)。 + */ +function LayoutWorkbench({ + projects, + appendedAsset, +}: { + projects: ProjectFixture[]; + appendedAsset?: AssetFixture; +}) { + const [activeIndex, setActiveIndex] = React.useState(0); + const [manifests, setManifests] = React.useState< + Record + >(() => + Object.fromEntries( + projects.map((project) => [ + project.projectId, + manifestFor(project.projectId, project.assets), + ]), + ), + ); + const active = projects[activeIndex]!; + const manifest = manifests[active.projectId]!; + + return ( + <> + {appendedAsset ? ( + + ) : null} + {projects.length > 1 ? ( + + ) : null} + Supervisor} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => + setManifests((current) => { + const projectId = nextManifest.projectId; + return { ...current, [projectId]: nextManifest }; + }) + } + /> + + ); +} + +afterEach(() => { + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +describe('资源画布手动重排口径', () => { + it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => { + const projectId = 'manual-rederive-project'; + const projectPath = '/tmp/manual-rederive-project'; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: 3, + positions: [ + { + resourceId: 'resource-b', + section: 'document', + x: 600, + y: 40, + manuallyPlaced: true, + }, + { + resourceId: 'resource-a', + section: 'document', + x: 900, + y: 900, + manuallyPlaced: false, + }, + ], + updatedAt: 300, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: 4, + positions: args?.positions, + updatedAt: 400, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke }, + } as unknown as typeof window.__TAURI__; + + const resources: ResourceCanvasItem[] = ['resource-a', 'resource-b'].map( + (id) => ({ + id, + category: 'document' as ProjectResourceCanvasCategory, + subtype: 'agent-result', + label: id, + mediaType: 'text/markdown', + dependencyDepth: 0, + }), + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources, + rederiveAutomaticPositions: false, + }), + ); + + await waitFor(() => expect(result.current.ready).toBe(true)); + // 自动卡还停在落后坐标上:preserve 口径不会自己去纠正它。 + expect( + result.current.layout.positions.find( + (position) => position.resourceId === 'resource-a', + ), + ).toMatchObject({ x: 900, y: 900 }); + + await act(async () => { + result.current.rederiveNow(); + }); + + await waitFor(() => + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_canvas_layout', + ), + ).toHaveLength(1), + ); + const written = invoke.mock.calls.find( + ([command]) => command === 'update_local_project_resource_canvas_layout', + )?.[1]?.positions as ProjectResourceCanvasPosition[]; + expect(written).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-a', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-b', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + }); + + it('新素材入库后既有自动卡坐标逐值不变,新卡只补在末尾', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-layout-project': 'manual-layout-project', + }, + }); + render( + , + ); + + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + const before = typeWrites(tauri).at(-1)!; + expect( + before.positions.map((position) => position.resourceId), + ).not.toContain(NEW_RESOURCE_ID); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect( + typeWrites(tauri).some((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ), + ).toBe(true), + ); + const after = typeWrites(tauri).find((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + )!; + + // 核心判据:除新卡外全部既有坐标逐值不变(顺序、分区、手动标记都不许动)。 + expect( + after.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ).toEqual( + before.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ); + }); + + it('依赖画布只在关系图首次就绪时重算一次,之后新增素材不再重排', async () => { + const projectPath = '/tmp/manual-dependency-project'; + const tauri = installLayoutTauri({ + projectIdsByPath: { [projectPath]: 'manual-dependency-project' }, + layoutByScope: { + [`${projectPath}|dependency`]: [ + { + resourceId: 'asset:asset-art-b', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'asset:asset-art-a', + section: 'character', + x: 900, + y: 900, + manuallyPlaced: false, + }, + ], + }, + }); + render( + , + ); + + // 一次性重派生:关系图首次就绪后按最终拓扑把落后的自动坐标对齐一次。 + await waitFor(() => + expect( + dependencyWrites(tauri).some((write) => + write.positions.some( + (position) => + position.resourceId === 'asset:asset-art-a' && + (position.x !== 900 || position.y !== 900), + ), + ), + ).toBe(true), + ); + const before = dependencyWrites(tauri).at(-1)!; + expect( + before.positions.map((position) => position.resourceId), + ).not.toContain(NEW_RESOURCE_ID); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect( + dependencyWrites(tauri).some((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ), + ).toBe(true), + ); + const after = dependencyWrites(tauri).find((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + )!; + + expect( + after.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ).toEqual( + before.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ); + }); + + it('新素材入库后自动进入视口并被选中', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { '/tmp/manual-focus-project': 'manual-focus-project' }, + }); + render( + , + ); + + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set([NEW_RESOURCE_ID]), + ), + ); + }); + + it('新素材被搜索条件挡住时走既有「清除搜索并定位」路径', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-focus-hidden-project': 'manual-focus-hidden-project', + }, + }); + render( + , + ); + + await findResourceSelectButton('art-b.png'); + const search = openResourceFilterPanel(); + fireEvent.change(search, { target: { value: 'art-b' } }); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '测试:入库新素材' })); + + expect( + await screen.findByText('新资源已保存,但被当前搜索条件隐藏'), + ).not.toBeNull(); + // 搜索条件只由显式动作清除,不静默改用户输入。 + expect(openResourceFilterPanel().value).toBe('art-b'); + fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' })); + + await waitFor(() => + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set([NEW_RESOURCE_ID]), + ), + ); + expect(tauri.unexpectedCommands).toEqual([]); + }); + + it('「整理画布」按 rederive 重算自动坐标、保留手动坐标,并给出一次可见反馈', async () => { + const projectPath = '/tmp/manual-rederive-button-project'; + const tauri = installLayoutTauri({ + projectIdsByPath: { + [projectPath]: 'manual-rederive-button-project', + }, + layoutByScope: { + [`${projectPath}|type`]: [ + { + resourceId: 'asset:asset-art-a', + section: 'character', + x: 600, + y: 40, + manuallyPlaced: true, + }, + { + resourceId: 'asset:asset-art-b', + section: 'character', + x: 800, + y: 800, + manuallyPlaced: false, + }, + ], + }, + }); + render( + , + ); + + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-art-b"]'), + ).not.toBeNull(), + ); + // 切到「类型」视图:两个排序模式各有一份 sidecar,按钮作用于当前生效的那一份。 + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + // 打开项目 / 切排序 tab 这两步都不该重排:自动卡的落后坐标原样保留。 + expect(typeWrites(tauri)).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + expect(typeWrites(tauri)[0]!.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:asset-art-b', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'asset:asset-art-a', + section: 'character', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + expect(await screen.findByText('布局已保存')).not.toBeNull(); + + // 已经整齐之后再按一次不产生第二次落盘:重算结果与当前坐标一致时不写(既有「截断关系图 + // 不得持久化自动布局」用例依赖同一条 `changed` 门)。 + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + await settleFocusChain(); + expect(typeWrites(tauri)).toHaveLength(1); + }); + + it('首次打开项目与切项目都不触发新素材聚焦跳转', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-open-project-a': 'manual-open-project-a', + '/tmp/manual-open-project-b': 'manual-open-project-b', + }, + }); + render( + , + ); + + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-a1"]'), + ).not.toBeNull(), + ); + // 布局读写先跑完,再给聚焦裁决链一次"要是会被误触发就已经触发"的机会。 + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + await settleFocusChain(); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' })); + + await waitFor(() => + expect( + tauri.layoutReads.some( + (read) => read.projectPath === '/tmp/manual-open-project-b', + ), + ).toBe(true), + ); + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-b1"]'), + ).not.toBeNull(), + ); + await waitFor(() => + expect( + typeWrites(tauri).some( + (write) => write.projectPath === '/tmp/manual-open-project-b', + ), + ).toBe(true), + ); + await settleFocusChain(); + expect(selectedResourceIdsInDom()).toEqual([]); + }); + + it('资源协调签名仍把新增素材算作变化(重排判据没有被人为掐掉)', () => { + const single = createResourceSignature([ + { + id: 'asset:x', + category: 'character', + subtype: 'character', + label: 'x', + mediaType: 'image/png', + dependencyDepth: 0, + }, + ]); + const doubled = createResourceSignature([ + { + id: 'asset:x', + category: 'character', + subtype: 'character', + label: 'x', + mediaType: 'image/png', + dependencyDepth: 0, + }, + { + id: 'asset:y', + category: 'character', + subtype: 'character', + label: 'y', + mediaType: 'image/png', + dependencyDepth: 0, + }, + ]); + expect(single).not.toBe(doubled); + }); +}); -- 2.52.0 From 3b8ecb02d41f0da20cca6c87535029c69ef4e68f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 17:08:00 +0800 Subject: [PATCH 03/21] =?UTF-8?q?=E8=AE=B0=E5=BD=95=20AGC=20=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E7=94=BB=E5=B8=83=E3=80=8C=E6=89=8B=E5=8A=A8=E6=95=B4?= =?UTF-8?q?=E7=90=86=E3=80=8D=E5=8F=A3=E5=BE=84=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decision-log:追加 2026-09-14 条目,记录默认 preserve、显式「整理画布」、 依赖侧关系图首次就绪的一次性重派生、新素材按 manifest 新增 id 聚焦四条决策与变异验证 - pitfalls:追加「不要恢复无条件重派生」条目,含 changed 门、ready 时序、 项目作用域 flag 与新素材基线四个易错点 --- docs/project-memory/shared-memory/decision-log.md | 10 ++++++++++ docs/project-memory/shared-memory/pitfalls.md | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7ed8870b7..120f5928b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3,6 +3,16 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-14 AGC 资源画布改为「手动整理」:新素材不再自动重排,整张重排只由「整理画布」发起 + +- 背景:生成一张新素材会让整张资源画布重排。两个 layout hook 都把 `rederiveAutomaticPositions` 打开(type 侧无条件 `true`,dependency 侧长期等于 `resourceGraphReady`),而该开关的语义是「每次资源协调签名变化就丢掉全部 `manuallyPlaced=false` 坐标、按当前资源与拓扑整体重算」;新增一张素材必然改签名,于是既有自动卡全部跟着挪位,用户刚记住的位置就没了。画布上也没有任何显式整理入口(`复位资源视图` 只复位视口)。 +- 决策一(默认口径):两个 mode 的 `rederiveAutomaticPositions` 固定 `false`——画布默认只补新卡,不动任何既有坐标(`preserve`)。整张重排改为显式动作:依赖/类型 tab 组新增「整理画布」按钮,调用 hook 新暴露的 `rederiveNow()`,复用既有写队列与 sidecar 写回链路,只把策略换成 `rederive`;用户可见反馈继续用既有 `resourceLayoutNotice`(成功即「布局已保存」)与 `resourceLayoutSaving`,不新增状态位。重算结果与当前坐标一致时**不落盘**(沿用既有 `changed` 门):已经整齐的画布按一下不该白推进一次 CAS / revision,关系图 `producerMappingTruncated` 时更不该把一份来自不完整关系图的自动布局写进 sidecar——既有用例 `keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout` 就是钉这条。 +- 决策二(依赖图首次就绪的那一次):`dependencyDepth` 仍要在关系图就绪后按最终拓扑排一次列。这一层不再靠「让布尔长期为真」,而是按**项目作用域的一次性 flag**(`dependencyRederiveScopeRef`):关系图就绪且该侧 sidecar `ready` 的那一刻调用一次 `rederiveNow()`,之后一律 `preserve`;切排序 tab 不重新武装,切项目才重新记一次。 +- 决策三(生成后自动聚焦):新增 `seenManifestAssetIdsRef` + effect,按 `manifest.assets` 的**新增 id**(不是 diff 位置、也不是文件名)把新卡交给既有 `pendingResourceFocusRef` + `advanceFocusGeneration()` 裁决链。首次打开项目 / 切项目只登记基线、不聚焦;重命名不改 id、天然不触发;已有指向同一资源的聚焦意图时不重复挂(显式生成链路在提交时就已挂好)。被搜索条件挡住时继续复用既有的「清除搜索并定位」提示与动作。 +- 影响范围:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`(写意图增 `rederive` 标记、写策略分支、`rederiveNow`)、`.../index.tsx`(两个 hook 配置 + 一次性重派生 effect + 新素材聚焦 effect + 「整理画布」按钮)、`apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx`。不改 `manuallyPlaced` 语义、不改 sidecar 的 CAS / revision 协议与字段形状、不改卡片尺寸模型、不改「搜索不重排」合同、不改 Rust 资源图与 `dependencyDepth` 权威。 +- 验证方式:新增 `resourceCanvasManualLayout.test.tsx` 8 条:新素材入库后除新卡外坐标逐值不变、依赖侧首次就绪重算一次后同样不再重排、新素材自动聚焦、被搜索挡住走既有提示与动作、「整理画布」按 `rederive` 重算并保留手动坐标且给出一次可见反馈、已经整齐时再按一次不产生第二次落盘、首次打开与切项目都不聚焦。变异验证(均已实测):① type 侧改回 `true`、dependency 侧改回 `resourceGraphReady` → 3 条红;② 去掉新素材基线的首次登记 → 3 条红(既有卡被当成"刚生成"选中);③ 去掉 hook 里的 `rederive` 写策略分支 → 3 条红;④ 把显式整理改成强制写回(去掉 `changed` 门)→ 既有「截断关系图」用例红。 +- 关联文档:[踩坑记录](pitfalls.md)、[项目开发工作台 PRD](../../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md)。 + ## 2026-09-14 客户端 CI 按门禁组拆成三个 job,AGC 的 web / rust 两段并行 - 背景:`Project CI / Native shell tests` 把微信壳、Expo 移动壳、Tauri 桌面壳、H5 HostBridge 与 AI 游戏创作壳的全部门禁串在一个 job 里,实测 18 分 37 秒;同一次运行的 Repository / Frontend / Backend 分别只要 3 分 21 秒、4 分 16 秒、6 分 14 秒,其余三个 job 结束后客户端 job 还要再跑十几分钟。日志时间戳显示门禁段 932 秒里:AGC `ai-game-creator-shell:check` 占 654 秒(其中壳内 Rust 套件 2451 个用例 `--test-threads=1` 单跑 441.58 秒、编译 79 秒),AGC vitest 75 秒,两个发布构建 smoke 加落盘断言 230 秒,而 h5 / 微信 / 移动 / 桌面壳的全部运行时门禁加起来不到 50 秒。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 24af67d1b..429c9a238 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,14 @@ # 踩坑与排障记录 +## 2026-09-14 AGC 资源画布不要恢复「无条件重派生」,否则新增一张素材就整张重排 + +- **现象**:用户生成一张新素材后,画布上既有卡片全部移位,刚摆好的位置失效。 +- **原因**:`useProjectResourceCanvasLayout` 的 `rederiveAutomaticPositions=true` 会让**每一次资源协调签名变化**(新增 / 删除素材、改标签、改分类、拓扑签名变化)都丢掉全部 `manuallyPlaced=false` 坐标整体重算。签名里必然包含新素材,所以「新增一张素材」就等于「整张画布重排」;两个 hook 当时都开着它(type 侧无条件 `true`,dependency 侧长期等于 `resourceGraphReady`)。 +- **处理(现行口径)**:默认一律 `preserve`(只补新卡)。整张重排只由「整理画布」按钮调用 hook 的 `rederiveNow()` 发起,或由「关系图首次就绪」那一次按项目作用域的一次性 flag 发起。**不要**把 `rederiveAutomaticPositions` 改回长期 `true` / `resourceGraphReady`,也不要为「拓扑变了要立刻重排」再加自动触发点——那正是本条要修掉的行为。 +- **易错点**:① 一致性判据是 `reconcileResourceCanvasLayout` 输出里**每个分区按 `(y, x, resourceId)` 排序**后的数组与来源逐项比较,所以手工构造 sidecar 夹具时要按同序写,否则会被判成「变了」而多写一次,用例里会看到意料之外的写回;② 关系图首次就绪那一次重算要等该侧 sidecar `ready` 之后再发(关系图可能先就绪),否则这一次会被吃掉;③ 依赖侧的一次性 flag 按**项目作用域**记账,不要挂到 `resourceGraphReady` 这类会随排序 tab 反复翻转的值上,否则每次切回依赖视图都会重排一次;④ 新素材聚焦按 `manifest.assets` 的新增 id 判定,首次打开 / 切项目必须先登记基线,否则一进工作台就跳到最后的卡上;⑤ 不要为了让「整理画布」按钮"一定有反馈"而把 `changed` 门拿掉——重算结果与当前坐标一致时不写盘是既有合同,关系图 `producerMappingTruncated` 时强行写回等于把一份来自不完整关系图的自动布局持久化(`appSurface.test.ts` 的 `keeps trusted truncated-graph depths through the workbench without persisting a flat automatic layout` 会红)。 +- **验证**:`npx vitest run apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx`(8 条);把两个 hook 配置改回旧口径会红 3 条。 +- **关联**:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`。 + ## 2026-09-14 客户端 CI 拆分后,选组运行会跳过未选分组,且必须同步分支保护 - **现象**:把 `Native shell tests` 拆成客户端三个 job 后,如果只跑 `npm run check:native-shells:release`,静态契约和壳运行时门禁都不会执行;如果只跑 `--groups=contract`,`desktop-release-binary-artifact` 又会因为缺少 `build/native/desktop/` 产物而失败。 -- 2.52.0 From 181e364d80264f4678cec54fa4d7bfc951df5f80 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 17:13:43 +0800 Subject: [PATCH 04/21] =?UTF-8?q?AGC=20=E8=B5=84=E6=BA=90=E5=8D=A1?= =?UTF-8?q?=E6=A3=8B=E7=9B=98=E6=A0=BC=E5=BA=95=E6=94=B9=E6=8C=89=E5=9B=BE?= =?UTF-8?q?=E5=83=8F=E7=9C=9F=E5=AE=9E=20alpha=20=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原生预览新增头部级 alpha 判据:`LocalProjectImagePreview.hasAlpha`(PNG colorType 4/6 或 tRNS、WebP VP8X/VP8L alpha 标志;JPEG 恒 false),只读签名与 chunk 头、不做像素解码。 - 前端预览 payload 增加可选 `hasAlpha`,并在 `materializeProjectResourceCardPreview` 的两条分支里透传。 - 资源卡根节点新增排障属性 `data-preview-has-alpha`:只有预览已加载且判据为真才写 `'true'`,其余一律不写(缺属性与不透明同档)。 - 棋盘格底选择器加 `[data-preview-has-alpha='true']` 条件;无 alpha 时退回卡片既有纯色底,不引入第二套底色。 - 新增 TS 用例(真渲染资源卡 + styles.css 声明级层叠求值)与 Rust 用例(PNG/WebP/JPEG 头部判据、tRNS、坏文件失败关闭、非解码证明、IPC 字段序列化契约)。 - 验收文档补记 `data-preview-has-alpha` 判据与「真假棋盘格」取证脚本,便于现场区分真透明底与 AI 画出的假棋盘格。 --- .../src-tauri/src/image_inspect.rs | 289 ++++++++++++ apps/ai-game-creator-shell/src/styles.css | 20 +- .../src/view/project-development/index.tsx | 12 + .../resourceCardPreviewModel.ts | 13 + .../useProjectResourceCardPreviews.ts | 3 + ...esourceCardPreviewAlphaBackground.test.tsx | 434 ++++++++++++++++++ ...用例】AGC资源工作台V3端到端验收-2026-09-11.md | 8 + 7 files changed, 776 insertions(+), 3 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index c7c4313e1..edbdeb1ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -26,6 +26,12 @@ pub(crate) struct LocalProjectImagePreview { pub(crate) byte_len: u64, pub(crate) pixel_width: u32, pub(crate) pixel_height: u32, + /// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。 + /// + /// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」, + /// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图 + /// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。 + pub(crate) has_alpha: bool, pub(crate) data_url: String, } @@ -102,12 +108,17 @@ pub(crate) fn load_local_project_image_preview_with_cancellation( false, )?; cancellation.check()?; + // 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`), + // 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」 + // 的不透明图都不会因此变慢。 + let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type); Ok(LocalProjectImagePreview { path: image.relative_path.clone(), media_type: image.media_type.to_string(), byte_len: image.byte_len, pixel_width: image.pixel_width, pixel_height: image.pixel_height, + has_alpha, data_url: image.data_url_with_cancellation(cancellation)?, }) } @@ -420,6 +431,84 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32 } } +/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志 +/// (PNG 还会按 chunk 头跳过数据体找 `tRNS`)。 +/// +/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取, +/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB), +/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。 +/// +/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格): +/// - PNG:颜色类型 4(灰度 + alpha)/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道, +/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`; +/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位; +/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`); +/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。 +fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool { + match media_type { + "image/png" => detect_png_has_alpha(bytes), + "image/webp" => detect_webp_has_alpha(bytes), + _ => false, + } +} + +fn detect_png_has_alpha(bytes: &[u8]) -> bool { + // 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。 + if bytes.len() < 26 || &bytes[12..16] != b"IHDR" { + return false; + } + if matches!(bytes[25], 4 | 6) { + return true; + } + png_has_transparency_chunk(bytes) +} + +/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。 +fn png_has_transparency_chunk(bytes: &[u8]) -> bool { + let mut offset = 8usize; + loop { + let Some(header_end) = offset.checked_add(8) else { + return false; + }; + if header_end > bytes.len() { + return false; + } + let chunk_type = &bytes[offset + 4..header_end]; + // `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。 + if chunk_type == b"tRNS" { + return true; + } + if chunk_type == b"IDAT" || chunk_type == b"IEND" { + return false; + } + let chunk_len = + u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize; + let Some(next) = header_end + .checked_add(chunk_len) + .and_then(|value| value.checked_add(4)) + else { + return false; + }; + if next <= offset || next > bytes.len() { + return false; + } + offset = next; + } +} + +fn detect_webp_has_alpha(bytes: &[u8]) -> bool { + if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { + return false; + } + match &bytes[12..16] { + // `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。 + b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0), + // `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。 + b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0, + _ => false, + } +} + #[derive(Clone, Copy)] enum TiffByteOrder { LittleEndian, @@ -745,6 +834,74 @@ mod tests { .expect("valid 1x1 png") } + /// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。 + fn png_header(color_type: u8) -> Vec { + png_header_with_size(color_type, 1, 1) + } + + fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); + ihdr.push(color_type); + ihdr.extend_from_slice(&[0, 0, 0]); + push_png_chunk(&mut bytes, b"IHDR", &ihdr); + bytes + } + + /// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。 + /// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明 + /// 判据没有解码像素。 + fn push_png_chunk(bytes: &mut Vec, kind: &[u8; 4], data: &[u8]) { + bytes.extend_from_slice( + &u32::try_from(data.len()) + .expect("chunk length") + .to_be_bytes(), + ); + bytes.extend_from_slice(kind); + bytes.extend_from_slice(data); + bytes.extend_from_slice(&[0, 0, 0, 0]); + } + + /// 扩展格式 WebP(`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。 + fn webp_vp8x(flags: u8) -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8X"); + bytes.extend_from_slice(&10_u32.to_le_bytes()); + bytes.push(flags); + bytes.extend_from_slice(&[0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0]); + bytes + } + + /// 无损 WebP(`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。 + fn webp_vp8l(has_alpha: bool) -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8L"); + bytes.extend_from_slice(&5_u32.to_le_bytes()); + bytes.push(0x2f); + bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]); + bytes + } + + /// 简单有损 WebP(`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走 + /// `VP8X` 扩展格式(+ `ALPH` chunk)。 + fn webp_vp8_simple() -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8 "); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes + } + fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec { let mut bytes = vec![0xff, 0xd8]; if let Some(payload) = app1_payload { @@ -840,6 +997,138 @@ mod tests { assert_eq!(preview.media_type, "image/png"); assert_eq!(preview.byte_len, png_bytes().len() as u64); assert!(preview.data_url.starts_with("data:image/png;base64,")); + // 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」—— + // 资源卡据此才铺棋盘格底。 + assert!(preview.has_alpha); + } + + #[test] + fn png_alpha_follows_color_type_and_transparency_chunk() { + let color_type_alpha = |color_type: u8| { + let mut bytes = png_header(color_type); + push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut bytes, b"IEND", &[]); + detect_raster_image_has_alpha(&bytes, "image/png") + }; + + // 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。 + assert!(color_type_alpha(4), "colorType 4 应判为有 alpha"); + assert!(color_type_alpha(6), "PNG-32(colorType 6)应判为有 alpha"); + // 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。 + assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha"); + assert!( + !color_type_alpha(2), + "PNG-24(colorType 2)不应判为有 alpha" + ); + assert!( + !color_type_alpha(3), + "colorType 3 无 tRNS 时不应判为有 alpha" + ); + // 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。 + assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha"); + + // 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。 + for color_type in [0_u8, 2, 3] { + let mut bytes = png_header(color_type); + push_png_chunk(&mut bytes, b"tRNS", &[0]); + push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut bytes, b"IEND", &[]); + assert!( + detect_raster_image_has_alpha(&bytes, "image/png"), + "colorType {color_type} + tRNS 也是真透明 PNG" + ); + } + + // tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。 + let mut late_trns = png_header(3); + push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut late_trns, b"tRNS", &[0]); + push_png_chunk(&mut late_trns, b"IEND", &[]); + assert!(!detect_raster_image_has_alpha(&late_trns, "image/png")); + } + + #[test] + fn jpeg_and_webp_alpha_follow_container_flags() { + // JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。 + assert!(!detect_raster_image_has_alpha( + &jpeg_bytes(40, 20, None), + "image/jpeg" + )); + // 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。 + assert!(detect_raster_image_has_alpha( + &webp_vp8x(0x10), + "image/webp" + )); + assert!(!detect_raster_image_has_alpha( + &webp_vp8x(0x00), + "image/webp" + )); + // 只有 ICC(0x20)/ EXIF(0x08)等其它标志时不是 alpha。 + assert!(!detect_raster_image_has_alpha( + &webp_vp8x(0x28), + "image/webp" + )); + // 无损 VP8L 的 alpha_is_used 位。 + assert!(detect_raster_image_has_alpha( + &webp_vp8l(true), + "image/webp" + )); + assert!(!detect_raster_image_has_alpha( + &webp_vp8l(false), + "image/webp" + )); + // 简单有损格式不带 alpha 通道。 + assert!(!detect_raster_image_has_alpha( + &webp_vp8_simple(), + "image/webp" + )); + + // 头部被截断时失败关闭为「不透明」,且不得 panic。 + let truncated_webp = webp_vp8x(0x10); + assert!(!detect_raster_image_has_alpha( + &truncated_webp[..18], + "image/webp" + )); + let truncated_png = png_header(6); + assert!(!detect_raster_image_has_alpha( + &truncated_png[..20], + "image/png" + )); + } + + #[test] + fn alpha_judgement_never_decodes_pixels() { + // 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法 + // deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功, + // 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。 + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + let mut bytes = png_header_with_size(6, 4_096, 4_096); + push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]); + push_png_chunk(&mut bytes, b"IEND", &[]); + fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image"); + + let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png") + .expect("header-only preview"); + + assert_eq!(preview.pixel_width, 4_096); + assert_eq!(preview.byte_len, bytes.len() as u64); + assert!(preview.has_alpha); + } + + #[test] + fn image_preview_serializes_alpha_flag_for_the_shell() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image"); + + let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png") + .expect("load project preview"); + + // 前端按 camelCase 读 `hasAlpha`(`ProjectResourceCardPreviewTransportPayload`); + // 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。 + let serialized = serde_json::to_value(&preview).expect("serialize preview"); + assert_eq!(serialized["hasAlpha"], serde_json::json!(true)); } #[test] diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index a0fec719e..af05c40b7 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -7023,10 +7023,24 @@ iframe.preview-frame { place-items: center; } -.game-resource-card[data-preview-kind='raster-image'] +/* + * 棋盘格底只铺给**这张图真的有 alpha 通道**的卡(`data-preview-has-alpha='true'`, + * 判据来自原生侧头部解析,见 `src-tauri/src/image_inspect.rs`),不再按「预览分支是图片」 + * 无条件铺。 + * + * 原因:AI 生成的「透明底」PNG 常常把棋盘格**画进像素里**。无条件铺底时,卡面棋盘格与图内 + * 棋盘格叠在一起,验收无法区分「真透明底」与「假棋盘格」;改为按真实 alpha 判定后两者可分。 + * + * 没有该属性时(JPEG 恒不透明;media-image / video 目前没有头部 alpha 判据)退回 + * `.game-resource-card-visual` 的既有纯色底(见上一条规则),不引入第二套底色, + * 因此不会出现「半透明叠色」之类的问题。 + */ +.game-resource-card[data-preview-kind='raster-image'][data-preview-has-alpha='true'] .game-resource-card-visual, -.game-resource-card[data-preview-kind='media-image'] .game-resource-card-visual, -.game-resource-card[data-preview-kind='video'] .game-resource-card-visual { +.game-resource-card[data-preview-kind='media-image'][data-preview-has-alpha='true'] + .game-resource-card-visual, +.game-resource-card[data-preview-kind='video'][data-preview-has-alpha='true'] + .game-resource-card-visual { background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%), linear-gradient(-45deg, #f1ebe7 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f1ebe7 75%), diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 60842a4f6..3979baebd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -974,6 +974,18 @@ const ResourceCard = memo(function ResourceCard({ data-preview-error={ preview.status === 'failed' ? preview.error : undefined } + // 这张图是否**真的**带 alpha 通道(原生头部判据:PNG colorType 4/6 或 tRNS、 + // WebP alpha 标志;JPEG 恒 false)。棋盘格底只允许铺在真透明图上 —— + // 否则「AI 把棋盘格画进像素里」的不透明图会与卡面棋盘格叠成两套,验收时反而 + // 分不出哪张真透明。 + // + // 只写 `'true'`,不写 `'false'`:缺属性表示「还没读出来 / 判据说不透明 / 这条读取链路 + // 没有 alpha 判据」,三者必须同档(CSS 里只认 `'true'`),免得出现第三种中间态。 + data-preview-has-alpha={ + preview.status === 'loaded' && preview.preview.hasAlpha === true + ? 'true' + : undefined + } data-used-by-current-version={usedByCurrentVersion ? 'true' : undefined} // 替换血缘的稳定 DOM 判据(值都是 manifest 资产 id,不是显示名): // 「被替换掉的源素材」卡上给出替换它的那张卡的 id,「替换素材」卡上给出源素材的 id。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts index 006048f62..8791b8d25 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts @@ -43,6 +43,19 @@ export type ProjectResourceCardPreviewPayload = { byteLen: number; pixelWidth?: number; pixelHeight?: number; + /** + * 这张图是否**真的**带 alpha 通道(原生侧头部级判据,见 `image_inspect.rs` 的 + * `detect_raster_image_has_alpha`):PNG 颜色类型 4/6 或 `tRNS`、WebP 的 alpha 标志为 true; + * JPEG 恒 false。 + * + * 资源卡的棋盘格底只按它铺(`data-preview-has-alpha='true'`),不再按「预览分支是图片」 + * 无条件铺 —— 否则 AI 把棋盘格画进像素里的不透明图会与卡面棋盘格叠在一起, + * 验收时无法区分「真透明底」与「假棋盘格」。 + * + * 只有 `read_local_project_image_preview` 这条图像读取链路会给出该字段;文本 / 媒体预览的 + * payload 没有它(`undefined`),必须与 `false` 同档处理:不知道就不铺棋盘格。 + */ + hasAlpha?: boolean; sourceUrl?: string; content?: string; }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 94d17a065..06309ad28 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -164,6 +164,7 @@ function materializeProjectResourceCardPreview( byteLen: transport.byteLen, pixelWidth: transport.pixelWidth, pixelHeight: transport.pixelHeight, + hasAlpha: transport.hasAlpha, content: transport.content, }, retainedBytes: @@ -200,6 +201,8 @@ function materializeProjectResourceCardPreview( byteLen: transport.byteLen, pixelWidth: imageDimensions?.pixelWidth, pixelHeight: imageDimensions?.pixelHeight, + // 头部级 alpha 判据随图像预览 payload 一起透传:卡面棋盘格底只认它。 + hasAlpha: transport.hasAlpha, sourceUrl: objectUrl, }, retainedBytes: blob.size, diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx new file mode 100644 index 000000000..b611522f5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx @@ -0,0 +1,434 @@ +// @vitest-environment jsdom +/** + * 资源卡的棋盘格底必须由**这张图真实的 alpha** 决定,而不是「预览分支是图片」。 + * + * 现场缺陷(验收截图):所有 PNG/JPEG 卡一律铺 CSS 棋盘格,于是「真透明底」与 + * 「AI 把棋盘格画进像素里」在卡面上完全同形,验收时无法区分两者。 + * + * 本文件把整条链路钉住: + * 1. 真的渲染 `ProjectDevelopmentView`,用假的 `read_local_project_image_preview` + * 返回原生头部判据 `hasAlpha`,断言卡片根节点的 `data-preview-has-alpha`; + * 2. 把真实 DOM 上的 `data-preview-kind` / `data-preview-has-alpha` 喂给 + * `styles.css` 的声明级层叠求值,断言只有真透明卡片的 `.game-resource-card-visual` + * 最终生效声明里才有棋盘格(判据取 `background-size: 16px 16px` 与渐变层)。 + * + * 为什么不用 `getComputedStyle(visual).backgroundImage`:jsdom 不加载样式表,且本仓库 + * 测试环境的 cssstyle 解析不了渐变 —— 实测把 `background: linear-gradient(...)` 与 + * `background-image: linear-gradient(...)` 写进 `