接入RPG场景参考图原生导入

RPG场景图片参考图在原生壳内优先走宿主图片导入

场景参考图取消原生选择时不触发浏览器文件输入

同步场景参考图测试、宿主壳协议和共享记忆
This commit is contained in:
2026-06-19 12:15:19 +08:00
parent 6d79e89db1
commit d14d577143
5 changed files with 154 additions and 6 deletions
@@ -2689,3 +2689,10 @@
- 决策:`CoverImageGenerationModal` 的封面参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readImageFileAsDataUrl` 读取、参考图预览和 `generateCustomWorldCoverImage` payload 链路。用户取消宿主选择时不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
- 影响范围:`src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx``src/components/CustomWorldEntityEditorModal.test.tsx`、宿主壳能力统一协议文档。
- 验证方式:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx -t "作品封面"``npm run typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
## 2026-06-19 RPG 场景参考图接入原生壳图片导入
- 背景:RPG 场景图片 AI 生成弹层的自定义参考图仍通过浏览器隐藏文件输入读取;在 Expo / Tauri 壳中没有复用已落地的受控 `file.importImage` 系统选择器。
- 决策:`SceneImageGenerationModal` 的场景图片参考图上传在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片副本转换成浏览器 `File` 后继续走现有 `readImageFileAsDataUrl` 读取、参考图预览和 `rpgCreationAssetClient.generateSceneImage` payload 链路。用户取消宿主选择时不连带触发浏览器文件输入;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。
- 影响范围:`src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx``src/components/CustomWorldEntityEditorModal.test.tsx`、宿主壳能力统一协议文档。
- 验证方式:`npm run test -- src/components/CustomWorldEntityEditorModal.test.tsx``npm run typecheck``npm run check:native-shells``npm run check:encoding``git diff --check`
@@ -227,6 +227,7 @@ npm run check:native-shells
RPG 角色资产工作室的角色参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 `readFileAsDataUrl` 参考图集合和角色形象生成 payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。
RPG 作品封面上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 10 MiB 校验、图片尺寸读取、16:9 裁剪和 `uploadCustomWorldCoverImage` 保存链路;用户取消原生选择不应再连带弹出浏览器文件输入。
RPG 作品封面参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 `readImageFileAsDataUrl` 读取、预览和 `generateCustomWorldCoverImage` payload 链路;用户取消原生选择不应再连带弹出浏览器文件输入。
RPG 场景图片参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后复用现有 `readImageFileAsDataUrl` 读取、预览和 `rpgCreationAssetClient.generateSceneImage` 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 壳一起验收。
File diff suppressed because one or more lines are too long
@@ -31,6 +31,7 @@ import {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
vi.mock('../data/characterPresets', async () => {
@@ -1748,6 +1749,108 @@ test('开局场景列表与详情幕预览复用同一套幕级图片', async ()
).toBe('/generated-custom-world-scenes/camp-act-2.png');
});
test('场景图片参考图在原生壳内优先走 HostBridge 图片导入并进入生成 payload', async () => {
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({
action: 'selected',
fileName: 'native-scene-reference.png',
base64Data:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
mimeType: 'image/png',
bytes: 68,
});
mockedRpgCreationAssetClient.generateSceneImage.mockClear();
mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/native-reference-scene.png',
assetId: 'asset-native-reference-scene',
model: 'wan2.2-t2i-flash',
size: '1280*720',
taskId: 'task-native-reference-scene',
prompt: '带参考图的场景图',
});
class MockFileReader {
result: string | null = null;
error: Error | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
const firstActCard = getSceneActCard(0);
await user.click(
within(firstActCard).getByRole('button', { name: '配置背景' }),
);
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
});
await user.click(screen.getByText('上传自定义参考图'));
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
expect(screen.getByText('已载入自定义参考图')).toBeTruthy();
});
expect(inputClickSpy).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '开始生成' }));
await waitFor(() => {
expect(
mockedRpgCreationAssetClient.generateSceneImage,
).toHaveBeenCalledTimes(1);
});
const payload =
mockedRpgCreationAssetClient.generateSceneImage.mock.calls[0]?.[0];
expect(payload?.referenceImageSrc).toMatch(/^data:image\/png;base64,/u);
});
test('场景图片参考图取消原生导入时不触发浏览器文件输入', async () => {
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
(capability) => capability === 'file.importImage',
);
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
const firstActCard = getSceneActCard(0);
await user.click(
within(firstActCard).getByRole('button', { name: '配置背景' }),
);
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
});
await user.click(screen.getByText('上传自定义参考图'));
await waitFor(() => {
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
});
expect(inputClickSpy).not.toHaveBeenCalled();
expect(screen.queryByText('已载入自定义参考图')).toBeNull();
});
test('开局场景幕背景智能生成复用当前幕图片和幕级提示词', async () => {
mockedRpgCreationAssetClient.generateSceneImage.mockClear();
mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({
@@ -3180,6 +3180,7 @@ function SceneImageGenerationModal({
useState<CustomWorldSceneImageResult | null>(null);
const [isExitConfirmOpen, setIsExitConfirmOpen] = useState(false);
const referenceImageInputId = useId();
const referenceImageInputRef = useRef<HTMLInputElement | null>(null);
const originalImageSrc = useMemo(() => {
const initialPreview = initialPreviewImageSrc?.trim() || '';
@@ -3203,11 +3204,7 @@ function SceneImageGenerationModal({
const previewImageSrc = latestResult?.imageSrc || originalImageSrc;
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
const setSceneReferenceImageFromFile = async (file: File) => {
if (!file) {
return;
}
@@ -3225,6 +3222,39 @@ function SceneImageGenerationModal({
}
};
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
event.currentTarget.value = '';
if (!file) {
return;
}
await setSceneReferenceImageFromFile(file);
};
const handleReferenceImageUploadClick = () => {
if (!canUseNativeHostCapability('file.importImage')) {
referenceImageInputRef.current?.click();
return;
}
void (async () => {
const importedImage = await importHostImageFile();
if (!importedImage) {
return;
}
await setSceneReferenceImageFromFile(
puzzleReferenceImageDataUrlToFile(
`data:${importedImage.mimeType};base64,${importedImage.base64Data}`,
importedImage.fileName,
),
);
})();
};
const handleRequestClose = () => {
if (isGenerating) {
return;
@@ -3304,8 +3334,15 @@ function SceneImageGenerationModal({
size="compact"
surface="editorDark"
disabled={isGenerating}
onClick={(event) => {
event.preventDefault();
if (!isGenerating) {
handleReferenceImageUploadClick();
}
}}
/>
<input
ref={referenceImageInputRef}
id={referenceImageInputId}
type="file"
accept="image/png,image/jpeg,image/webp"