接入RPG角色参考图原生导入

RPG角色资产工作室参考图在原生壳内优先走宿主图片导入

取消原生图片选择时不触发浏览器文件输入

同步角色资产工作室测试、宿主壳协议和共享记忆
This commit is contained in:
2026-06-19 11:53:59 +08:00
parent 98daf41790
commit bf50c6fab6
6 changed files with 155 additions and 9 deletions
@@ -2668,3 +2668,10 @@
- 决策:`Match3DResultView` 发布封面图和封面参考图在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readPuzzleReferenceImageAsDataUrl`、AI 重绘开关、参考图集合和 `generateMatch3DCoverImage` payload 链路。用户取消宿主选择时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
- 影响范围:`src/components/match3d-result/Match3DResultView.tsx``src/components/match3d-result/Match3DResultView.test.tsx`、宿主壳能力统一协议文档。
- 验证方式:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx``npm run typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-19 RPG 角色参考图接入原生壳图片导入
- 背景:RPG 角色资产工作室的角色参考图仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器。
- 决策:`RpgCreationRoleAssetStudioModal` 的角色参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readFileAsDataUrl`、参考图集合和角色形象生成 payload 链路。用户取消宿主选择时停留在壳流程内,不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
- 影响范围:`src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx``src/components/rpg-creation-asset-studio/RpgCreationRoleVisualSection.tsx``src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx`、宿主壳能力统一协议文档。
- 验证方式:`npm run test -- src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal.test.tsx``npm run typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
@@ -224,6 +224,7 @@ npm run check:native-shells
固定内置 H5 体验入口在原生壳声明 `navigation.openNativePage` 时必须优先走 `navigateHostNativePage()`;例如儿童动作热身 Demo 从平台首页进入 `/child-motion-demo` 时应由 HostBridge 发出 `navigation.openNativePage`,宿主不可用时才回退浏览器跳转。
汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。
抓大鹅结果页发布封面图和封面参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。
RPG 角色资产工作室的角色参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 `readFileAsDataUrl` 参考图集合和角色形象生成 payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。
视觉小说结果页封面 / 角色 / 场景图片和音乐 / 环境音上传在原生壳声明 `file.importImage` / `file.importAudio` 时必须优先走宿主受控导入,并把 H5 base64 转 `File` 后继续交给 `uploadVisualNovelAsset` 与当前素材字段写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,历史素材选择和 AI 图片生成保持原链路。
该命令会反查微信小程序 `WECHAT_HOST_CAPABILITIES` 与共享 `HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES` 一致;小程序生产代码继续保留 CommonJS 运行时镜像,不直接 import TypeScript shared 包。
该命令同时会运行微信小程序 `miniprogram/host-bridge/``miniprogram/shell/``pages/web-view` 样式和 `scripts/miniprogram-web-view-auth.test.ts` 的壳层测试,保证微信桥接层拆分后的支付、订阅消息、九宫切图、分享目标和 WebView 登录 / 分享入口行为与 Expo、Tauri 壳一起验收。
@@ -72,7 +72,7 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。
- `importHostDocumentFile()`:原生 App 宿主的受控文档导入入口。Expo 移动壳通过 Expo DocumentPickerTauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `text/plain``text/markdown``text/csv``application/json``application/vnd.openxmlformats-officedocument.wordprocessingml.document` 或对应 `.txt` / `.md` / `.markdown` / `.csv` / `.json` / `.docx` 扩展名,单次不超过 5 MiB。成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI、本机绝对路径或通用文件系统能力;宿主必须在读取 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文档导入,把返回 base64 转换成现有浏览器 `File` 后继续调用 `/api/runtime/creation-agent/document-inputs/parse`;旧壳只声明 `file.importText` 时才回退到文本导入,普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。该能力不在前端解析 DOCX,也不绕过后端文档解析、大小校验或错误口径。
- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台参考图上传、轻输入 composer 参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传、抓大鹅结果页发布封面 / 封面参考图上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 工作台继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,轻输入 composer 继续复用 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,抓大鹅结果页继续复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页和轻输入 composer 在移动壳声明 `file.captureImage` 时额外展示拍摄入口,并把拍摄结果复用同一图片校验与提交链路。在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;创作 Agent 工作台参考图上传、轻输入 composer 参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传、抓大鹅结果页发布封面 / 封面参考图上传、RPG 角色资产工作室角色参考图上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 工作台继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,轻输入 composer 继续复用 `readPuzzleReferenceImageAsDataUrl` 的图片类型、大小、压缩和 data URL 预览链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,抓大鹅结果页继续复用现有封面 data URL 读取、AI 重绘开关、参考图集合和封面生成 payload 链路,RPG 角色资产工作室继续复用现有 `readFileAsDataUrl` 参考图集合和角色形象生成 payload 链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;反馈页和轻输入 composer 在移动壳声明 `file.captureImage` 时额外展示拍摄入口,并把拍摄结果复用同一图片校验与提交链路。在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `scanHostQrCode()`:原生 App 宿主的受控二维码扫描入口。Expo 移动壳声明 `scanner.scanQrCode`,通过 `expo-camera` 的真实相机权限和 `CameraView` 扫描 QR code,成功只返回清洗后的二维码文本与 `qr_code` 格式,单次值最多保留 4096 字符且拒绝空值和控制字符;用户关闭或系统取消返回 `cancelled`,H5 不会继续连带弹出浏览器摄像头权限。Tauri 桌面壳只把 `scanner.scanQrCode` 保留在 method 白名单中用于明确返回 `unsupported_method`,不声明 capability、不伪造桌面扫码。个人中心扫码入口在 `native_app` 且宿主声明该能力时优先调用原生扫码;宿主不支持、旧壳缺能力或扫码结果非法时继续打开现有浏览器摄像头扫码弹层,普通浏览器和小程序保持原有路径。
HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。H5 事件订阅入口必须同时要求 `host.events` 和对应事件 capability,不能仅凭 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped` 单项能力就绑定事件监听;旧壳或裁剪壳缺任一能力时订阅应返回空取消函数。`npm run check:native-shells` 会反查共享事件清单、H5 订阅 facade 和 `canUseNativeHostEventCapability(...)`,防止后续事件订阅绕过双能力门控。
@@ -2,9 +2,10 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import type { EditableCustomWorldRole } from './roleAssetStudioModel';
import * as hostBridgeServices from '../../services/host-bridge/hostBridge';
import { RpgCreationRoleAssetStudioModal } from './RpgCreationRoleAssetStudioModal';
const generateVisualCandidatesForRole = vi.fn();
@@ -14,6 +15,11 @@ vi.mock('../asset-studio/projectPixelStyleReference', () => ({
buildProjectPixelStyleReferenceBoard: vi.fn(async () => ''),
}));
vi.mock('../../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(() => false),
importHostImageFile: vi.fn(),
}));
vi.mock('../asset-studio/characterAssetWorkflowPersistence', async () => {
const actual = await vi.importActual<
typeof import('../asset-studio/characterAssetWorkflowPersistence')
@@ -61,12 +67,18 @@ vi.mock('../CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
}));
test('角色形象生成通过统一确认弹窗确认泥点消耗', async () => {
const user = userEvent.setup();
const confirmSpy = vi
.spyOn(window, 'confirm')
.mockImplementation(() => false);
const role: EditableCustomWorldRole = {
afterEach(() => {
vi.restoreAllMocks();
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockReturnValue(
false,
);
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
generateVisualCandidatesForRole.mockReset();
applyVisualDraftToRole.mockReset();
});
function createRole(): EditableCustomWorldRole {
return {
id: 'role-shen-li',
name: '沈砺',
title: '潮灯守望者',
@@ -74,6 +86,14 @@ test('角色形象生成通过统一确认弹窗确认泥点消耗', async () =>
visualDescription: '银发剑客,青色长衫。',
actionDescription: '轻盈奔跑动作。',
};
}
test('角色形象生成通过统一确认弹窗确认泥点消耗', async () => {
const user = userEvent.setup();
const confirmSpy = vi
.spyOn(window, 'confirm')
.mockImplementation(() => false);
const role = createRole();
generateVisualCandidatesForRole.mockResolvedValue({
drafts: [
@@ -157,3 +177,69 @@ test('角色形象生成通过统一确认弹窗确认泥点消耗', async () =>
confirmSpy.mockRestore();
});
test('角色参考图在原生壳内优先走 HostBridge 图片导入', async () => {
const user = userEvent.setup();
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({
action: 'selected',
fileName: 'role-reference.png',
base64Data: 'cm9sZS1yZWZlcmVuY2U=',
mimeType: 'image/png',
bytes: 14,
});
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
render(
<RpgCreationRoleAssetStudioModal
role={createRole()}
roleKind="playable"
onClose={() => {}}
visualPointCost={7}
/>,
);
await screen.findByRole('button', { name: //u });
await user.click(screen.getByText('上传角色参考图'));
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
expect(screen.getByText('已载入 1 张参考图。')).toBeTruthy();
});
expect(inputClickSpy).not.toHaveBeenCalled();
});
test('角色参考图取消原生导入时不触发浏览器文件输入', async () => {
const user = userEvent.setup();
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
render(
<RpgCreationRoleAssetStudioModal
role={createRole()}
roleKind="playable"
onClose={() => {}}
visualPointCost={7}
/>,
);
await screen.findByRole('button', { name: //u });
await user.click(screen.getByText('上传角色参考图'));
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
});
expect(inputClickSpy).not.toHaveBeenCalled();
expect(screen.queryByText('已载入 1 张参考图。')).toBeNull();
});
@@ -25,6 +25,11 @@ import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformMudPointConfirmDialog } from '../common/PlatformMudPointConfirmDialog';
import {
canUseNativeHostCapability,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage';
import {
CORE_ACTIONS,
type CustomWorldAiActionConfig,
@@ -809,6 +814,39 @@ export function RpgCreationRoleAssetStudioModal({
event.target.value = '';
};
const replaceReferenceImagesFromFiles = async (files: File[]) => {
if (files.length === 0) {
return;
}
const uploadedDataUrls = await Promise.all(
files.slice(0, 4).map((file) => readFileAsDataUrl(file)),
);
setReferenceImageDataUrls(uploadedDataUrls);
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。`);
};
const handleReferenceImageUploadClick = (fallback: () => void) => {
if (!canUseNativeHostCapability('file.importImage')) {
fallback();
return;
}
void (async () => {
const importedImage = await importHostImageFile();
if (!importedImage) {
return;
}
await replaceReferenceImagesFromFiles([
puzzleReferenceImageDataUrlToFile(
`data:${importedImage.mimeType};base64,${importedImage.base64Data}`,
importedImage.fileName,
),
]);
})();
};
const applyVisualDraftToWorkflow = async (draft: CharacterVisualDraft) => {
setIsApplyingVisual(true);
try {
@@ -1056,6 +1094,7 @@ export function RpgCreationRoleAssetStudioModal({
onGenerateVisuals={() => {
requestGenerateVisuals();
}}
onReferenceImageUploadClick={handleReferenceImageUploadClick}
onVisualPromptChange={setVisualPromptText}
/>
@@ -1,5 +1,5 @@
import { ImagePlus, RefreshCcw } from 'lucide-react';
import { type ChangeEvent, type ReactNode, useId } from 'react';
import { type ChangeEvent, type ReactNode, useId, useRef } from 'react';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
@@ -54,6 +54,7 @@ export function RpgCreationRoleVisualSection(props: {
workingRoleName: string;
onClearReferenceImages: () => void;
onGenerateVisuals: () => void;
onReferenceImageUploadClick: (fallback: () => void) => void;
onVisualPromptChange: (value: string) => void;
}) {
const {
@@ -76,9 +77,11 @@ export function RpgCreationRoleVisualSection(props: {
workingRoleName,
onClearReferenceImages,
onGenerateVisuals,
onReferenceImageUploadClick,
onVisualPromptChange,
} = props;
const referenceImageInputId = useId();
const referenceImageInputRef = useRef<HTMLInputElement | null>(null);
const visualPreviewImageSrc =
previewImageSrc || selectedTemplatePortrait || '';
const visualPreviewAlt = previewImageSrc
@@ -122,8 +125,18 @@ export function RpgCreationRoleVisualSection(props: {
size="compact"
surface="editorDark"
disabled={isGeneratingVisuals || isApplyingVisual || syncBusy}
onClick={(event) => {
event.preventDefault();
if (isGeneratingVisuals || isApplyingVisual || syncBusy) {
return;
}
onReferenceImageUploadClick(() =>
referenceImageInputRef.current?.click(),
);
}}
/>
<input
ref={referenceImageInputRef}
id={referenceImageInputId}
type="file"
accept="image/png,image/jpeg,image/webp"