接入移动端壳相机图片导入能力

新增 HostBridge file.captureImage 契约与 H5 facade

Expo 移动壳通过系统相机拍摄图片并复用图片导入校验

通用图片输入面板按宿主能力展示拍摄入口并转换为现有 File 回调

补充移动壳、HostBridge、图片面板测试和原生壳文档
This commit is contained in:
2026-06-18 06:03:45 +08:00
parent b3278739a5
commit f7126f9556
13 changed files with 509 additions and 76 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
"expo-image-picker",
{
"photosPermission": "允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。",
"cameraPermission": false,
"cameraPermission": "允许 Genarrative 使用相机拍摄创作素材和参考图。",
"microphonePermission": false
}
],
+9 -5
View File
@@ -155,11 +155,11 @@ if (Array.isArray(imagePickerPlugin)) {
if (typeof pluginOptions.photosPermission !== 'string') {
throw new Error('mobile shell image picker photo permission text is missing');
}
if (
pluginOptions.cameraPermission !== false ||
pluginOptions.microphonePermission !== false
) {
throw new Error('mobile shell image picker must not request camera or microphone');
if (typeof pluginOptions.cameraPermission !== 'string') {
throw new Error('mobile shell image picker camera permission text is missing');
}
if (pluginOptions.microphonePermission !== false) {
throw new Error('mobile shell image picker must not request microphone');
}
}
@@ -182,6 +182,7 @@ for (const snippet of [
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'clipboard.readText',
@@ -197,7 +198,9 @@ for (const snippet of [
'DocumentPicker.getDocumentAsync',
'Clipboard.getStringAsync',
'ImagePicker.launchImageLibraryAsync',
'ImagePicker.launchCameraAsync',
'ImagePicker.requestMediaLibraryPermissionsAsync',
'ImagePicker.requestCameraPermissionsAsync',
'File(asset.uri)',
'file.base64()',
'normalizeHostBridgeExportFileName',
@@ -236,6 +239,7 @@ for (const capability of [
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
@@ -109,7 +109,9 @@ vi.mock('expo-image-picker', () => ({
DENIED: 'denied',
GRANTED: 'granted',
},
launchCameraAsync: vi.fn(),
launchImageLibraryAsync: vi.fn(),
requestCameraPermissionsAsync: vi.fn(),
requestMediaLibraryPermissionsAsync: vi.fn(),
}));
@@ -230,6 +232,18 @@ afterEach(() => {
canceled: true,
assets: null,
});
vi.mocked(ImagePicker.launchCameraAsync).mockReset();
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: true,
assets: null,
});
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockReset();
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockReset();
vi.mocked(ImagePicker.requestMediaLibraryPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
@@ -292,6 +306,9 @@ describe('handleMobileHostBridgeMessage', () => {
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importImage');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.captureImage');
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('file.importAudio');
@@ -1046,6 +1063,71 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectFailed(oversized).error.code).toBe('invalid_request');
});
test('file.captureImage 调起系统相机并返回受控图片数据', async () => {
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/camera.jpg',
width: 120,
height: 80,
type: 'image',
fileName: null,
fileSize: 6,
base64: 'Y2FtZXJh',
mimeType: 'image/jpeg',
},
],
});
const response = await send(request('file.captureImage'));
expect(expectOk(response).result).toEqual({
action: 'captured',
fileName: 'genarrative-import.jpg',
base64Data: 'Y2FtZXJh',
mimeType: 'image/jpeg',
bytes: 6,
});
expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalled();
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({
allowsEditing: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
});
test('file.captureImage 拒绝权限和取消拍摄', async () => {
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.DENIED,
granted: false,
canAskAgain: false,
expires: 'never',
});
const denied = await send(request('file.captureImage'));
expect(expectFailed(denied).error.code).toBe('host_error');
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
vi.mocked(ImagePicker.requestCameraPermissionsAsync).mockResolvedValue({
status: ImagePicker.PermissionStatus.GRANTED,
granted: true,
canAskAgain: true,
expires: 'never',
});
vi.mocked(ImagePicker.launchCameraAsync).mockResolvedValue({
canceled: true,
assets: null,
});
const cancelled = await send(request('file.captureImage'));
expect(expectFailed(cancelled).error.code).toBe('cancelled');
});
test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => {
fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8=');
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
+49 -18
View File
@@ -105,6 +105,7 @@ export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
@@ -559,23 +560,10 @@ async function exportAudioFile(
};
}
async function importImageFile(): Promise<FileImportImageResult> {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== ImagePicker.PermissionStatus.GRANTED) {
throw {
code: 'host_error',
message: 'photo library permission denied',
} satisfies HostBridgeError;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: false,
allowsMultipleSelection: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
function imagePickerResultToImportPayload(
result: ImagePicker.ImagePickerResult,
action: FileImportImageResult['action'],
): FileImportImageResult {
if (result.canceled) {
throw {
code: 'cancelled',
@@ -610,7 +598,7 @@ async function importImageFile(): Promise<FileImportImageResult> {
}
return {
action: 'selected',
action,
fileName: normalizeHostBridgeExportFileName(
asset.fileName || fallbackImportedImageFileName(mimeType),
),
@@ -620,6 +608,47 @@ async function importImageFile(): Promise<FileImportImageResult> {
};
}
async function importImageFile(): Promise<FileImportImageResult> {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== ImagePicker.PermissionStatus.GRANTED) {
throw {
code: 'host_error',
message: 'photo library permission denied',
} satisfies HostBridgeError;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: false,
allowsMultipleSelection: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
return imagePickerResultToImportPayload(result, 'selected');
}
async function captureImageFile(): Promise<FileImportImageResult> {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== ImagePicker.PermissionStatus.GRANTED) {
throw {
code: 'host_error',
message: 'camera permission denied',
} satisfies HostBridgeError;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: false,
base64: true,
exif: false,
mediaTypes: ['images'],
quality: 1,
});
return imagePickerResultToImportPayload(result, 'captured');
}
async function importAudioFile(): Promise<FileImportAudioResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
@@ -908,6 +937,8 @@ async function handleRequest(request: HostBridgeRequest) {
return ok(request, await exportImageFile(request.payload));
case 'file.importImage':
return ok(request, await importImageFile());
case 'file.captureImage':
return ok(request, await captureImageFile());
case 'file.importAudio':
return ok(request, await importAudioFile());
case 'file.exportAudio':
@@ -33,8 +33,9 @@
- 2026-06-18 原生壳生命周期事件:新增 `app.lifecycle` HostBridge capabilityExpo 壳通过 React Native `AppState` 派发 `active` / `inactive` / `background`Tauri 壳通过主窗口 focus / blur 派发 `active` / `inactive`H5 只通过 `subscribeHostAppLifecycle()` 订阅统一状态,后续游戏循环、音频和轮询暂停 / 恢复不得直接依赖 Expo / Tauri 平台细节。
- 2026-06-18 原生壳网络状态:新增 `network.status``network.statusChanged` HostBridge capabilityExpo 壳通过 `expo-network` 查询和订阅真实系统网络状态,Tauri 壳通过短超时连接 `app.genarrative.world:443` 查询主站可达性,并通过 WebView `online` / `offline` 注入变化事件;H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。
- 2026-06-18 桌面图片导入:新增 `file.importImage``file.imageDropped` HostBridge capabilityTauri 壳通过系统文件选择框和主窗口拖拽事件读取用户选择 / 拖入的真实图片,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB;H5 统一使用 `importHostImageFile()` / `subscribeHostImageDrop()`,宿主只回传文件名、MIME、base64 内容、字节数和可选坐标,不暴露本地绝对路径,也不开放通用文件系统。
- 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB;成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,不请求相机或麦克风权限,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`
- 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel``native_app` 且声明 `file.importImage` 时,主图上传和描述参考图上传优先调用 `importHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="file">` 路径,不新增玩法侧上传分叉
- 2026-06-18 移动图片导入:Expo 壳开始声明并实现 `file.importImage`,通过 `expo-image-picker` 请求相册权限并打开系统相册选择器,只允许 `image/png``image/jpeg``image/webp` 且单次不超过 10 MiB;成功只回传清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI,用户取消返回 `cancelled` 并由 H5 facade 归为 `false`
- 2026-06-18 移动图片拍摄导入:Expo 壳新增 `file.captureImage` HostBridge capability,通过 `expo-image-picker` 请求相机权限并打开系统相机拍摄图片,沿用 `file.importImage` 的 MIME、体积、base64 和文件名清洗规则,成功回传 `action=captured`,不暴露设备本地 URI,也不请求麦克风权限;Tauri 壳不声明能力,不伪造桌面拍摄
- 2026-06-18 H5 图片上传接入宿主导入:`CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时,主图上传和描述参考图上传可分别调用 `importHostImageFile()` / `captureHostImageFile()`,并把宿主返回的 base64 图片转换为现有 `File` 回调;浏览器、小程序和未声明能力的裁剪壳继续走原生 `<input type="file">` 路径,不新增玩法侧上传分叉。
- 2026-06-18 桌面图片拖入接入主图槽位:`CreativeImageInputPanel` 在桌面壳声明 `file.imageDropped` 时订阅宿主拖入事件,只在拖入坐标命中当前主图卡片且未被上层元素遮挡时消费事件,避免窗口级拖入被多个创作面板同时接收;成功后仍转换为现有 `File` 上传回调。
- 2026-06-18 H5 背景音乐接入宿主生命周期:`useBackgroundMusic` 通过 `useHostLifecycleActive()` 消费 `subscribeHostAppLifecycle()` 的归一结果,宿主进入后台、inactive 或桌面窗口失焦时降低音量并暂停音频循环,同时 `suspend` WebAudio context;回到 `active + focused` 且用户原本开启音乐时再恢复播放,不改变用户音量设置。
- 2026-06-18 固定玩法音频接入宿主生命周期:前端新增 `useHostLifecycleActive()` 统一消费 `subscribeHostAppLifecycle()``useBackgroundMusic`、拼图运行态和抓大鹅运行态都只依赖该归一状态判断音频可播放性;宿主 inactive、background 或窗口失焦时暂停 `<audio>` / WebAudio,回到 `active + focused` 后仅在运行态仍在播放、音源存在且用户音乐音量大于 0 时恢复,不改变用户音量设置。
File diff suppressed because one or more lines are too long
@@ -60,7 +60,7 @@ AI H5 sandbox
- `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;用户取消时由 H5 facade 归为 `false`
- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。
- `importHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片;两端都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` 时优先调用宿主导入,并把结果转换成现有 `File` 回调;在桌面壳同时声明 `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` 回调;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg``audio/mp4``audio/wav``audio/ogg``audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力。H5 的通用音频输入面板 `CreativeAudioInputPanel``native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。单次音频不超过 20 MiB,成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob``fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。
@@ -60,6 +60,7 @@ describe('HostBridge shared contract helpers', () => {
expect(isHostBridgeCapability('clipboard.readText')).toBe(true);
expect(isHostBridgeCapability('file.importText')).toBe(true);
expect(isHostBridgeCapability('file.importImage')).toBe(true);
expect(isHostBridgeCapability('file.captureImage')).toBe(true);
expect(isHostBridgeCapability('file.importAudio')).toBe(true);
expect(isHostBridgeCapability('file.exportAudio')).toBe(true);
expect(isHostBridgeCapability('file.imageDropped')).toBe(true);
+2 -1
View File
@@ -32,6 +32,7 @@ export const HOST_BRIDGE_METHODS = [
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'haptics.impact',
@@ -327,7 +328,7 @@ export type HostBridgeImageMimeType =
| 'image/webp';
export type FileImportImageResult = {
action: 'selected' | 'dropped';
action: 'selected' | 'dropped' | 'captured';
fileName: string;
base64Data: string;
mimeType: HostBridgeImageMimeType;
@@ -11,12 +11,14 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
canUseNativeHostCapability,
captureHostImageFile,
importHostImageFile,
subscribeHostImageDrop,
} from '../../services/host-bridge/hostBridge';
import { CreativeImageInputPanel } from './CreativeImageInputPanel';
vi.mock('../../services/host-bridge/hostBridge', () => ({
captureHostImageFile: vi.fn(),
canUseNativeHostCapability: vi.fn(() => false),
importHostImageFile: vi.fn(),
subscribeHostImageDrop: vi.fn(() => () => undefined),
@@ -34,6 +36,7 @@ vi.mock('../ResolvedAssetImage', () => ({
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
const captureHostImageFileMock = vi.mocked(captureHostImageFile);
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
const importHostImageFileMock = vi.mocked(importHostImageFile);
const subscribeHostImageDropMock = vi.mocked(subscribeHostImageDrop);
@@ -868,6 +871,183 @@ test('creative image input panel imports prompt reference image from native host
expect(selectedFile?.type).toBe('image/webp');
});
test('creative image input panel captures the main image from native host capability', async () => {
const onMainImageFileSelect = vi.fn();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.captureImage',
);
captureHostImageFileMock.mockResolvedValue({
action: 'captured',
fileName: '拍摄图.jpg',
mimeType: 'image/jpeg',
base64Data: 'Y2FtZXJh',
bytes: 6,
});
try {
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="native-capture-main-image-input"
promptTextareaId="native-capture-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拍摄图片' }));
await waitFor(() => {
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
});
const selectedFile = onMainImageFileSelect.mock.calls[0]?.[0] as File;
expect(selectedFile.name).toBe('拍摄图.jpg');
expect(selectedFile.type).toBe('image/jpeg');
expect(inputClickSpy).not.toHaveBeenCalled();
expect(importHostImageFileMock).not.toHaveBeenCalled();
} finally {
inputClickSpy.mockRestore();
}
});
test('creative image input panel captures prompt reference image from native host capability', async () => {
const onPromptReferenceFilesSelect = vi.fn();
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.captureImage',
);
captureHostImageFileMock.mockResolvedValue({
action: 'captured',
fileName: '参考拍摄.png',
mimeType: 'image/png',
base64Data: 'cmVm',
bytes: 3,
});
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="native-capture-reference-main-image-input"
promptTextareaId="native-capture-reference-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onPromptReferenceFilesSelect={onPromptReferenceFilesSelect}
onSubmit={() => {}}
/>,
);
const captureButtons = screen.getAllByRole('button', { name: '拍摄图片' });
expect(captureButtons).toHaveLength(2);
fireEvent.click(captureButtons[1] as HTMLElement);
await waitFor(() => {
expect(onPromptReferenceFilesSelect).toHaveBeenCalledWith([
expect.any(File),
]);
});
const selectedFile = onPromptReferenceFilesSelect.mock.calls[0]?.[0]?.[0] as
| File
| undefined;
expect(selectedFile?.name).toBe('参考拍摄.png');
expect(selectedFile?.type).toBe('image/png');
expect(importHostImageFileMock).not.toHaveBeenCalled();
});
test('creative image input panel keeps callbacks quiet when native image capture is cancelled', async () => {
const onMainImageFileSelect = vi.fn();
canUseNativeHostCapabilityMock.mockImplementation(
(capability) => capability === 'file.captureImage',
);
captureHostImageFileMock.mockResolvedValue(false);
render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="cancelled-native-capture-image-input"
promptTextareaId="cancelled-native-capture-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拍摄图片' }));
await waitFor(() => {
expect(captureHostImageFileMock).toHaveBeenCalledTimes(1);
});
expect(onMainImageFileSelect).not.toHaveBeenCalled();
});
test('creative image input panel keeps callbacks quiet when native image import is cancelled', async () => {
const onMainImageFileSelect = vi.fn();
canUseNativeHostCapabilityMock.mockReturnValue(true);
+108 -26
View File
@@ -1,8 +1,16 @@
import { History, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
import {
Camera,
History,
ImagePlus,
Loader2,
Sparkles,
Trash2,
} from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import {
canUseNativeHostCapability,
captureHostImageFile,
type HostFileImportImageResult,
importHostImageFile,
subscribeHostImageDrop,
@@ -168,6 +176,7 @@ export function CreativeImageInputPanel({
const shouldShowMainImageUploadButton =
isMainImageUploadEnabled && shouldPreviewMainImage;
const canImportHostImage = canUseNativeHostCapability('file.importImage');
const canCaptureHostImage = canUseNativeHostCapability('file.captureImage');
const canReceiveHostImageDrop =
canUseNativeHostCapability('file.imageDropped');
const promptReferenceInputId = `${mainImageInputId}-prompt-reference`;
@@ -260,6 +269,14 @@ export function CreativeImageInputPanel({
return hostImageImportResultToFile(result);
};
const captureHostImageAsFile = async () => {
const result = await captureHostImageFile();
if (!result) {
return null;
}
return hostImageImportResultToFile(result);
};
const handleMainImageUploadClick = () => {
if (disabled || !isMainImageUploadEnabled) {
return;
@@ -281,6 +298,23 @@ export function CreativeImageInputPanel({
})();
};
const handleMainImageCaptureClick = () => {
if (disabled || !isMainImageUploadEnabled || !canCaptureHostImage) {
return;
}
void (async () => {
try {
const file = await captureHostImageAsFile();
if (file) {
onMainImageFileSelect(file);
}
} catch {
// 中文注释:相机拍摄失败或用户取消时保持当前表单状态,不回落到相册或文件框。
}
})();
};
const handlePromptReferenceUploadClick = () => {
if (
promptReferenceUploadDisabled ||
@@ -306,6 +340,28 @@ export function CreativeImageInputPanel({
})();
};
const handlePromptReferenceCaptureClick = () => {
if (
promptReferenceUploadDisabled ||
!shouldShowPromptReferences ||
!onPromptReferenceFilesSelect ||
!canCaptureHostImage
) {
return;
}
void (async () => {
try {
const file = await captureHostImageAsFile();
if (file) {
onPromptReferenceFilesSelect([file]);
}
} catch {
// 中文注释:相机拍摄失败不打断当前创作输入,用户可继续选择上传或重试拍摄。
}
})();
};
return (
<div
className={`creative-image-input-panel flex min-h-0 flex-col ${
@@ -423,6 +479,19 @@ export function CreativeImageInputPanel({
<span></span>
</PlatformIconButton>
) : null}
{isMainImageUploadEnabled && canCaptureHostImage ? (
<PlatformIconButton
variant="surfaceFloating"
label="拍摄图片"
title="拍摄图片"
disabled={disabled}
onClick={handleMainImageCaptureClick}
icon={<Camera className="h-3.5 w-3.5" />}
className={`absolute top-3 z-10 h-10 w-10 ${
shouldShowHistoryButton ? 'right-[4.75rem]' : 'right-3'
}`}
/>
) : null}
{canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? (
<PlatformPillSwitch
label="AI重绘"
@@ -517,31 +586,44 @@ export function CreativeImageInputPanel({
}}
className="sr-only"
/>
{canImportHostImage ? (
<PlatformIconButton
variant="surfaceFloating"
label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
disabled={promptReferenceUploadDisabled}
onClick={handlePromptReferenceUploadClick}
icon={<ImagePlus className="h-4 w-4" />}
className="absolute bottom-3 right-3 z-10 h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]"
/>
) : (
<PlatformIconButton
asChild="label"
htmlFor={promptReferenceInputId}
variant="surfaceFloating"
label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
icon={<ImagePlus className="h-4 w-4" />}
className={`absolute bottom-3 right-3 z-10 h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${
promptReferenceUploadDisabled
? 'cursor-not-allowed opacity-55'
: 'cursor-pointer'
}`}
/>
)}
<div className="absolute bottom-3 right-3 z-10 flex gap-2">
{canCaptureHostImage ? (
<PlatformIconButton
variant="surfaceFloating"
label="拍摄图片"
title="拍摄图片"
disabled={promptReferenceUploadDisabled}
onClick={handlePromptReferenceCaptureClick}
icon={<Camera className="h-3.5 w-3.5" />}
className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]"
/>
) : null}
{canImportHostImage ? (
<PlatformIconButton
variant="surfaceFloating"
label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
disabled={promptReferenceUploadDisabled}
onClick={handlePromptReferenceUploadClick}
icon={<ImagePlus className="h-4 w-4" />}
className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]"
/>
) : (
<PlatformIconButton
asChild="label"
htmlFor={promptReferenceInputId}
variant="surfaceFloating"
label={labels.promptReferenceUpload}
title={labels.promptReferenceUpload}
icon={<ImagePlus className="h-4 w-4" />}
className={`h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${
promptReferenceUploadDisabled
? 'cursor-not-allowed opacity-55'
: 'cursor-pointer'
}`}
/>
)}
</div>
</>
) : null}
</div>
+47 -20
View File
@@ -6,6 +6,7 @@ import type { HostBridgeCapability } from '../../../packages/shared/src/contract
import {
canUseHostShareGrid,
canUseNativeHostCapability,
captureHostImageFile,
exportHostAudioFile,
exportHostImageFile,
exportHostTextFile,
@@ -603,31 +604,39 @@ describe('hostBridge', () => {
? {
text: '作品号 PZ-1',
}
: request.method === 'file.importImage'
? {
action: 'selected',
fileName: '参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
}
: request.method === 'file.importText'
: request.method === 'file.importImage'
? {
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
fileName: '参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
}
: request.method === 'file.importAudio'
: request.method === 'file.captureImage'
? {
action: 'selected',
fileName: '敲击音效.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
action: 'captured',
fileName: '拍摄图.jpg',
base64Data: 'Y2FtZXJh',
mimeType: 'image/jpeg',
bytes: 6,
}
: true,
: request.method === 'file.importText'
? {
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
}
: request.method === 'file.importAudio'
? {
action: 'selected',
fileName: '敲击音效.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
}
: true,
};
});
window.history.replaceState(
@@ -651,6 +660,7 @@ describe('hostBridge', () => {
'file.importText',
'file.exportImage',
'file.importImage',
'file.captureImage',
'file.importAudio',
'file.exportAudio',
'file.imageDropped',
@@ -722,6 +732,13 @@ describe('hostBridge', () => {
mimeType: 'image/png',
bytes: 5,
});
await expect(captureHostImageFile()).resolves.toEqual({
action: 'captured',
fileName: '拍摄图.jpg',
base64Data: 'Y2FtZXJh',
mimeType: 'image/jpeg',
bytes: 6,
});
await expect(importHostTextFile()).resolves.toEqual({
action: 'selected',
fileName: '剧情.md',
@@ -1472,4 +1489,14 @@ describe('hostBridge', () => {
await expect(importHostImageFile()).resolves.toBe(false);
});
test('原生 App 宿主未声明拍摄图片能力时回退为 false', async () => {
window.history.replaceState(
null,
'',
nativeAppPath(['file.importImage']),
);
await expect(captureHostImageFile()).resolves.toBe(false);
});
});
+24 -1
View File
@@ -819,7 +819,9 @@ function normalizeHostImageImportResult(
if (
!payload ||
typeof payload !== 'object' ||
(payload.action !== 'selected' && payload.action !== 'dropped') ||
payload.action !== 'selected' &&
payload.action !== 'dropped' &&
payload.action !== 'captured' ||
!HOST_BRIDGE_IMAGE_MIME_TYPES.has(payload.mimeType) ||
typeof payload.fileName !== 'string' ||
!payload.fileName.trim() ||
@@ -875,6 +877,27 @@ export async function importHostImageFile() {
}
}
export async function captureHostImageFile() {
if (!canUseNativeHostCapability('file.captureImage')) {
return false;
}
try {
return normalizeHostImageImportResult(
await requestNativeAppHostBridge<FileImportImageResult>(
'file.captureImage',
undefined,
{ timeoutMs: 30000 },
),
);
} catch (error) {
if (isUnsupportedHostBridgeError(error)) {
return false;
}
throw error;
}
}
const HOST_BRIDGE_TEXT_MIME_TYPES = new Set<HostBridgeTextMimeType>([
'text/plain',
'text/markdown',