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 6d300849a..30316af5f 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 @@ -133,6 +133,9 @@ export function projectResourceCardPreviewEvictionIdentities( return Array.from(evicted); } +/** `read_local_project_media_preview` 只认这两个线上取值,与画布栏目轴无关。 */ +export type ProjectResourceMediaPreviewCategory = 'art' | 'audio'; + const rasterImageExtension = /\.(png|jpe?g|webp)$/iu; const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu; const videoExtension = /\.(mp4|webm|mov)$/iu; @@ -177,6 +180,22 @@ export function projectResourceCardPreviewKind( return 'placeholder'; } +/** + * `read_local_project_media_preview` 的线上 `category` 入参:只按文件类型分美术 / 音频两支。 + * + * 该参数在原生侧只接受 `art` / `audio`(见 `commands.rs` 的 + * `read_local_project_media_preview_at`),它是**文件读取分支**,不是画布分区栏目。 + * `ProjectResource.category` 自 6 类资产分类轴落地后已经是画布栏目(`unclassified` / + * `ui-interaction` / …),把它当线上分类传给原生侧会被直接拒绝 + * (`媒体预览类别只支持 art 或 audio`),扩展名图片、视频与音频卡片因此全部读不出预览。 + * 这里从卡片预览类型重新派生,保持与分区栏目解耦。 + */ +export function projectResourceMediaPreviewCategory( + resource: ProjectResource, +): ProjectResourceMediaPreviewCategory { + return projectResourceCardPreviewKind(resource) === 'audio' ? 'audio' : 'art'; +} + export function projectResourceCardPreviewIdentity(input: { projectPath: string; projectId: 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 d5478a532..f6e38cb0f 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 @@ -24,15 +24,14 @@ import { projectResourceCardPreviewEvictionIdentities, projectResourceCardPreviewIdentity, projectResourceCardPreviewImageDimensions, + type ProjectResourceCardPreviewKind, projectResourceCardPreviewKind, type ProjectResourceCardPreviewPayload, type ProjectResourceCardPreviewState, type ProjectResourceCardPreviewTransportPayload, + projectResourceMediaPreviewCategory, } from './resourceCardPreviewModel'; -import { - type ProjectResource, - projectResourceDisplayKind, -} from './resourceProjectionModel'; +import type { ProjectResource } from './resourceProjectionModel'; type PreviewRequestReason = 'visible' | 'detail' | 'play'; @@ -61,8 +60,10 @@ type ObservedPreviewCard = { resource: ProjectResource; }; -function resourceReadKindLabel(resource: ProjectResource) { - const kind = projectResourceDisplayKind(resource); +function resourceReadKindLabel( + resource: ProjectResource, + kind: ProjectResourceCardPreviewKind, +) { if (kind === 'document') { return '文档'; } @@ -74,18 +75,20 @@ function resourceReadKindLabel(resource: ProjectResource) { function previewReadErrorMessage( resource: ProjectResource, + kind: ProjectResourceCardPreviewKind, error: unknown, ): { error: string; retryable: boolean } { const message = error instanceof Error ? error.message : String(error); + const kindLabel = resourceReadKindLabel(resource, kind); if (message.includes('项目权限策略要求用户确认')) { return { - error: `当前项目策略要求先确认读取${resourceReadKindLabel(resource)},确认后请关闭详情并重试`, + error: `当前项目策略要求先确认读取${kindLabel},确认后请关闭详情并重试`, retryable: true, }; } if (message.includes('项目权限策略拒绝执行')) { return { - error: `当前项目策略不允许读取${resourceReadKindLabel(resource)},调整策略后请关闭详情并重试`, + error: `当前项目策略不允许读取${kindLabel},调整策略后请关闭详情并重试`, retryable: true, }; } @@ -398,7 +401,7 @@ export function useProjectResourceCardPreviews(input: { { projectPath: input.projectPath, relativePath: job.resource.path, - category: job.resource.category, + category: projectResourceMediaPreviewCategory(job.resource), scopeId: job.scopeId, requestId: createProjectResourcePreviewRequestId(), }, @@ -426,6 +429,7 @@ export function useProjectResourceCardPreviews(input: { } activeReadsRef.current.count += 1; pendingIdentitiesRef.current.set(job.identity, job.scopeEpoch); + const jobPreviewKind = projectResourceCardPreviewKind(job.resource); publishPreview(job.identity, { status: 'loading' }); void readPreview(job) .then((transport) => { @@ -450,7 +454,11 @@ export function useProjectResourceCardPreviews(input: { isCurrentJob(job) && !isProjectResourcePreviewCancellation(error) ) { - const failure = previewReadErrorMessage(job.resource, error); + const failure = previewReadErrorMessage( + job.resource, + jobPreviewKind, + error, + ); publishPreview(job.identity, { status: 'failed', ...failure, diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 54e058cd8..8d22f2f3d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -1163,6 +1163,13 @@ export function registerProjectWorkbenchFoundationTests() { resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); + const tsxSource = readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/view/project-development/index.tsx', + ), + 'utf8', + ); // 卡片(z-index 25)与栏目标题栏(30)都画在铺满资源区的场景里:工具条必须比场景更高, // 否则放开 display 也只是"画了但看不见、点不到"。 const sceneZIndex = styleNumber( @@ -1181,6 +1188,35 @@ export function registerProjectWorkbenchFoundationTests() { const notice = styleRuleBody(styles, '\\.game-resource-live-notice'); expect(notice).toMatch(/position:\s*relative/u); expect(styleNumber(notice, 'z-index')).toBeGreaterThan(sceneZIndex); + + // 但"在场景之上"只是不被盖住,不等于两者不重叠:上一版把搜索框常态居中浮在画本顶上, + // 分页态栏目标题栏正好被压在同一水平带上。所以还要钉住"工具条带独占顶部那条带"。 + const scene = styleRuleBody(styles, '\\.game-resource-book-scene'); + expect(scene).toMatch(/inset:\s*var\(--game-resource-book-tools-height\)/u); + + // 管理区给工具条带留出的高度与场景让出的顶边必须是同一个变量,否则两者会再次互相压住。 + const manager = styleRuleBody(styles, '\\.game-resource-manager'); + expect(manager).toMatch( + /padding-top:\s*var\(--game-resource-book-tools-height\)/u, + ); + + // 工具条带是绝对定位的独立排布行,不是流内浮层;搜索框按整行宽度算, + // 不与任何栏目标题栏共享同一水平带。 + const tools = styleRuleBody(styles, '\\.game-resource-book-tools'); + expect(tools).toMatch(/position:\s*absolute/u); + expect(styleNumber(tools, 'top')).toBe(0); + + // 搜到的是场景里真正的栏目标题栏(分页态被激活时铺满整行),它必须整体排在工具条带下面。 + const titlebar = styleRuleBody( + styles, + '\\.game-resource-book-scene-titlebar', + ); + expect(styleNumber(titlebar, 'z-index')).toBeLessThan( + styleNumber(tools, 'z-index'), + ); + + expect(tsxSource).toContain('game-resource-book-tools'); + expect(tsxSource).not.toContain('game-resource-book-scene-titlebar'); }); it('keeps the outline dock readable without hover', () => { @@ -2552,8 +2588,9 @@ export function registerProjectWorkbenchFoundationTests() { }; } if (command === 'read_local_project_media_preview') { - // 媒体读取的 `category` 现在传资源所在的功能栏目(svg 为 ui-interaction、 - // 视频与音频为 unclassified),不能再按旧栏目 `art` 分流,改按路径返回。 + // 媒体读取的 `category` 是原生侧的**文件读取分支**(只认 art / audio), + // 不是资源所在的画布栏目:栏目取值(unclassified / ui-interaction / …) + // 传过去会被 `read_local_project_media_preview_at` 直接拒绝。 if (args?.relativePath === 'assets/bgm.mp3') { return { path: 'assets/bgm.mp3', @@ -2616,6 +2653,16 @@ export function registerProjectWorkbenchFoundationTests() { ); expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull(); + // SVG 在「UI 交互」栏目,但它走的是原生侧的美术读取分支:线上 `category` + // 必须是 art,不能是栏目取值 ui-interaction,否则原生侧直接拒绝、卡片读不出预览。 + expect( + invoke.mock.calls.some( + ([command, args]) => + command === 'read_local_project_media_preview' && + args?.relativePath === 'assets/icon.svg' && + args?.category === 'art', + ), + ).toBe(true); // 视频(animation)与音频(bgm)都不在 canonical kind 目录里,落在「待归类」栏目。 await openResourceBookCategory('待归类'); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index b02b24196..7b20d89f3 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -165,7 +165,10 @@ describe('useProjectResourceCardPreviews', () => { 'read_local_project_media_preview', expect.objectContaining({ relativePath: art.path, - category: 'unclassified', + // 线上合同的读取分支取值,不是资源所在的画布栏目:Rust 侧 + // `read_local_project_media_preview_at` 只接受 art / audio,传栏目值 + // (unclassified / ui-interaction / …)会被直接拒绝,卡片永远读不出预览。 + category: 'art', }), ); expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({ @@ -174,6 +177,79 @@ describe('useProjectResourceCardPreviews', () => { }); }); + it('sends the art read branch for extended images regardless of their canvas section', async () => { + const art = resource('extended-art', { + path: 'assets/extended-art.gif', + mediaType: 'image/gif', + // 6 类资产分类轴落地后的画布栏目取值,与线上读取分支是两个轴。 + category: 'unclassified', + }); + const invoke = vi.fn(async () => preview(art.path, 'image/gif')); + window.__TAURI__ = { core: { invoke } }; + const canvasRef = { current: document.createElement('div') }; + + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-extended-art', + projectId: 'preview-extended-art', + mode: 'dependency', + resources: [art], + canvasRef, + eagerPreviewLimit: 1, + }), + ); + const identity = result.current.identityByResourceId.get(art.id)!; + + await waitFor(() => + expect(result.current.previews.get(identity)?.status).toBe('loaded'), + ); + const mediaCall = invoke.mock.calls.find( + ([command]) => command === 'read_local_project_media_preview', + ); + expect(mediaCall?.[1]).toMatchObject({ + relativePath: art.path, + category: 'art', + }); + }); + + it('sends the audio read branch for a play request from an audio-section card', async () => { + const audio = resource('play-audio', { + path: 'assets/play-audio.mp3', + mediaType: 'audio/mpeg', + subtype: 'background-music', + category: 'audio', + }); + const invoke = vi.fn(async () => preview(audio.path, 'audio/mpeg')); + window.__TAURI__ = { core: { invoke } }; + const canvasRef = { current: document.createElement('div') }; + + const { result } = renderHook(() => + useProjectResourceCardPreviews({ + projectPath: '/tmp/preview-play-audio', + projectId: 'preview-play-audio', + mode: 'dependency', + resources: [audio], + canvasRef, + // 音频不预读:没有播放意图前必须一次 IPC 都不发。 + eagerPreviewLimit: 12, + }), + ); + const identity = result.current.identityByResourceId.get(audio.id)!; + expect(invoke).not.toHaveBeenCalled(); + + act(() => result.current.requestPreview(audio, identity, 'play')); + await waitFor(() => + expect(result.current.previews.get(identity)?.status).toBe('loaded'), + ); + expect(invoke).toHaveBeenCalledWith( + 'read_local_project_media_preview', + expect.objectContaining({ + relativePath: audio.path, + category: 'audio', + }), + ); + }); + it('only treats the exact native cancellation category as cancellation', () => { const cancellation = 'project-resource-preview-scope-cancelled'; expect(isProjectResourcePreviewCancellation(cancellation)).toBe(true);