修复资源卡片媒体预览发不出请求:线上 category 改回原生读取分支

- 新增 projectResourceMediaPreviewCategory:从卡片预览类型派生原生侧线上 category(art / audio),与画布分区栏目轴解耦
- read_local_project_media_preview 调用点不再传 job.resource.category:该字段自 6 类资产分类轴落地后是画布栏目(unclassified / ui-interaction / …),传给原生侧会被 read_local_project_media_preview_at 以「媒体预览类别只支持 art 或 audio」直接拒绝
- 该回归导致所有走媒体分支的卡片(GIF / SVG / AVIF / BMP / MP4 / WebM / MOV 与音频)读不出预览,只显示占位图标
- previewReadErrorMessage 与 resourceReadKindLabel 改用卡片预览类型判定文案,不再依赖已换语义的 category
- 废弃 useProjectResourceCardPreviews 对 projectResourceDisplayKind 的引用,错误文案与读取路由共用同一分类口径
- 预览 Hook 用例补「扩展名图片按 art 分支发出请求」与「音频仅在播放意图后按 audio 分支发出请求」两条断言,直接钉住原生侧线上取值
- 应用层媒体用例补 icon.svg 的 category 必须是 art 的断言,并修正原先描述「媒体读取改传画布栏目」的过期注释
This commit is contained in:
2026-09-10 20:59:22 +08:00
parent 2f8753f597
commit dde860716e
4 changed files with 163 additions and 13 deletions
@@ -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;
@@ -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,
@@ -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('待归类');
@@ -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);