diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 434d3c3b1..6ce254d94 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2564,6 +2564,13 @@ - 影响范围:`src/components/bark-battle-creation/BarkBattleResultView.tsx`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run test -- src/components/bark-battle-creation/BarkBattleResultView.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-19 视觉小说结果页复用原生图片和音频导入 + +- 背景:视觉小说结果页素材选择弹窗已经支持封面、角色立绘、场景背景、音乐和环境音上传,以及历史素材选择和 AI 图片生成;但在 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控图片 / 音频系统选择器体验。 +- 决策:`VisualNovelResultView` 的素材上传在 `native_app` 且宿主声明 `file.importImage` 或 `file.importAudio` 时优先调用 `importHostImageFile()` / `importHostAudioFile()`,把宿主返回的 base64 副本转换成浏览器 `File` 后继续交给 `uploadVisualNovelAsset` 上传和当前封面、角色、场景素材字段写回链路。用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入;历史素材选择和 AI 图片生成保持原链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 +- 影响范围:`src/components/visual-novel-result/VisualNovelResultView.tsx`、`scripts/check-native-shells.mjs`、宿主壳能力统一协议文档、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run test -- src/components/visual-novel-result/VisualNovelResultView.test.tsx`、`npm run typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 方洞结果页图片槽位接入原生壳图片导入 - 背景:方洞结果页的封面、背景、形状和洞口图片槽位已经支持浏览器文件输入、历史图选择、AI 生成和自动保存,但 Expo / Tauri 壳内点击上传仍只能触发 WebView 的浏览器文件输入,没有复用已落地的受控 `file.importImage` 能力。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 52accf30d..403c2364b 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -220,6 +220,7 @@ npm run check:native-shells 创作 Agent 原生壳文档导入优先走 `file.importDocument`,旧壳只声明 `file.importText` 时才回退文本导入;相关变更必须让根级和单端门禁覆盖共享 method、capability profile、文档 MIME / 5 MiB 上限、读取前 size 校验,以及 H5 base64 转 `File` 后继续走后端文档解析的链路。 创作 Agent 参考图上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给既有 `onReferenceImageChange` 校验链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。 汪汪声浪结果页玩家 / 对手 / UI 背景三图槽位上传在原生壳声明 `file.importImage` 时必须优先走宿主图片导入,并把 H5 base64 转 `File` 后继续交给 `uploadBarkBattleAsset` 与当前槽位写回链路;用户取消原生选择不应再连带弹出浏览器文件输入,普通浏览器、小程序和未声明能力的裁剪壳才使用原隐藏文件输入。 +视觉小说结果页封面 / 角色 / 场景图片和音乐 / 环境音上传在原生壳声明 `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 壳一起验收。 该命令还会反查微信小程序 `app.json.pages` 与 `host-bridge/protocol.js` 页面 URL、H5 小程序页面常量、H5 订阅授权页面常量、WebView 分享入口、分享目标消息类型、`WEB_VIEW_SOURCE_QUERY`、微信请求头运行时标记、H5 runtime parser、H5 路由保留字段和 H5 / API base URL 格式,避免页面路由、来源标记、宿主上下文 query 或域名配置在微信壳、H5 HostBridge 与运行时配置之间分叉。生产 / 开发 H5 与 API 域名都必须显式配置为纯 HTTPS domain,运行时开发域名回退生产域名只作为异常兜底。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index aea781a63..eba5f263a 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -314,6 +314,8 @@ GameBridge 禁止: 2026-06-19 追加:H5 创作 Agent 工作台在移动壳声明 `file.exportText` 时提供会话 Markdown 导出入口。H5 只把当前会话标题、摘要、进度、锚点、消息、流式回复和输入草稿组装成 `text/markdown` 文本,先按共享 5 MiB 上限计算 UTF-8 byte,再通过 `exportHostTextFile()` 交给 Expo 系统分享 / 保存面板;宿主取消、缺能力或 unsupported 时不做浏览器下载回退,保持原生壳文件保存只走受控 HostBridge 能力。 +2026-06-19 追加:视觉小说结果页素材选择弹窗在移动壳声明 `file.importImage` / `file.importAudio` 时优先打开宿主系统图片或音频选择器,把 HostBridge 返回的 base64 副本转换成浏览器 `File` 后继续调用 `uploadVisualNovelAsset`,不绕过平台素材上传、历史素材、AI 图片生成或草稿写回链路;用户取消原生选择时停留在壳流程内,不连带弹出浏览器文件输入。普通浏览器、小程序和未声明能力的裁剪壳继续使用原隐藏文件输入。 + 2026-06-18 追加:移动壳图片和音频导入 / 导出不再只信 Expo picker 返回的 MIME 或 H5 声明的 MIME。Expo 壳会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 base64 bytes 头部识别,导入时要求系统 picker 的 MIME 与 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。移动图片导出还会按 MIME 补齐 `.png` / `.jpg` / `.webp` 文件名扩展,避免系统分享 / 保存面板拿到无扩展或错扩展的缓存图片。 2026-06-18 追加:移动壳音频导入的 DocumentPicker `type` 参数包含 `audio/*`,用于兼容 iOS / Android 文件提供方只按通用音频类型暴露候选文件的情况;这不放宽 HostBridge 返回契约。系统返回 `application/octet-stream`、空 MIME 或其它宽泛 MIME 时,移动壳只会按 `.mp3` / `.m4a` / `.mp4` / `.wav` / `.ogg` / `.webm` 扩展名归一到允许 MIME,并继续执行 base64 bytes 匹配,扩展名或 bytes 任一不匹配都返回 `invalid_request`。 @@ -397,6 +399,8 @@ GameBridge 禁止: 2026-06-19 追加:桌面壳同样承接 H5 创作 Agent 会话 Markdown 导出。H5 侧只在宿主声明 `file.exportText` 且当前会话存在可导出内容时显示导出按钮,导出前完成 5 MiB UTF-8 byte 校验;Tauri 只负责系统保存对话框、文件名清洗、写入文本和返回文件名 / 字节数,不把 dialog、fs 或本机路径暴露给 H5。 +2026-06-19 追加:视觉小说结果页素材选择弹窗在桌面壳声明 `file.importImage` / `file.importAudio` 时同样优先打开 Tauri 受控系统选择框,把返回的图片或音频副本转换成浏览器 `File` 后继续调用 `uploadVisualNovelAsset` 和当前封面、角色、场景素材写回链路;Tauri 仍不暴露本机绝对路径、通用文件系统或 dialog JS guest API。普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。 + 2026-06-18 追加:桌面壳图片和音频导入 / 导出不再只信扩展名或 H5 声明的 MIME。Tauri Rust 侧会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 bytes 头部识别,导入时要求文件扩展名对应的允许 MIME 与真实 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。 2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Tauri 壳仍只通过 Rust 侧 clipboard-manager 返回纯文本,不开放插件 JS guest API;H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理。 diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index a01ed2f00..35695e5bc 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -72,11 +72,11 @@ 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 DocumentPicker,Tauri 桌面壳通过系统文件选择框读取用户选择的文档副本;两端都只接受 `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 工作台的参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传和汪汪声浪结果页三图槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路;在桌面壳同时声明 `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 工作台的参考图上传、反馈页上传凭证、个人资料头像上传、方洞结果页图片槽位上传、汪汪声浪结果页三图槽位上传和视觉小说结果页图片素材上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中创作 Agent 继续把宿主返回内容转换成现有浏览器 `File` 后交给 `onReferenceImageChange` 校验链路,反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路,汪汪声浪结果页继续把图片转换成浏览器 `File` 后交给 `uploadBarkBattleAsset` 上传和槽位写回链路,视觉小说结果页继续把图片转换成浏览器 `File` 后交给 `uploadVisualNovelAsset` 上传和当前封面 / 角色 / 场景素材写回链路;在桌面壳同时声明 `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(...)`,防止后续事件订阅绕过双能力门控。 -- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 +- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg`、`audio/mp4`、`audio/wav`、`audio/ogg`、`audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。H5 的通用音频输入面板 `CreativeAudioInputPanel` 在 `native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;视觉小说结果页音乐和环境音素材上传同样优先调用宿主音频导入,再把返回副本转换成浏览器 `File` 后继续交给 `uploadVisualNovelAsset` 上传和场景音频字段写回链路。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。 - `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` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。 ## 迁移顺序 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index d97e4c185..f9580aea3 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -84,6 +84,7 @@ const h5HostBridgeRequiredCallChainFiles = [ 'src/components/rpg-creation-result/RpgCreationAssetDebugPanel.tsx', 'src/components/rpg-entry/RpgEntryHomeView.tsx', 'src/components/square-hole-result/SquareHoleResultView.tsx', + 'src/components/visual-novel-result/VisualNovelResultView.tsx', 'src/hooks/useBackgroundMusic.ts', 'src/hooks/useHostLifecycleActive.ts', 'src/hooks/useHostNavigationCanGoBack.ts', @@ -332,6 +333,7 @@ const h5HostBridgeTests = [ 'src/components/common/CreativeAudioInputPanel.test.tsx', 'src/components/common/PublishShareModal.test.tsx', 'src/components/creation-agent/CreationAgentWorkspace.test.tsx', + 'src/components/visual-novel-result/VisualNovelResultView.test.tsx', 'src/components/platform-entry/platformDraftGenerationShelfModel.test.ts', 'src/components/platform-entry/platformHostBridgeSync.test.ts', 'src/components/platform-entry/platformHostNotificationModel.test.ts', diff --git a/src/components/visual-novel-result/VisualNovelResultView.test.tsx b/src/components/visual-novel-result/VisualNovelResultView.test.tsx index a9369ede6..5c6377b0d 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.test.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.test.tsx @@ -2,8 +2,13 @@ import { render, screen, 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 { + canUseNativeHostCapability, + importHostAudioFile, + importHostImageFile, +} from '../../services/host-bridge/hostBridge'; import { buildVisualNovelForbiddenCopyPattern } from '../visual-novel-runtime/visualNovelForbiddenCopy'; import { visualNovelLocalPreviewDraft } from '../visual-novel-runtime/visualNovelLocalPreviewData'; import { VisualNovelResultView } from './VisualNovelResultView'; @@ -24,6 +29,23 @@ vi.mock('../../services/assetReadUrlService', () => ({ shouldResolveAssetReadUrl: vi.fn(() => false), })); +vi.mock('../../services/host-bridge/hostBridge', () => ({ + canUseNativeHostCapability: vi.fn(() => false), + importHostAudioFile: vi.fn(), + importHostImageFile: vi.fn(), +})); + +const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability); +const importHostAudioFileMock = vi.mocked(importHostAudioFile); +const importHostImageFileMock = vi.mocked(importHostImageFile); + +afterEach(() => { + vi.clearAllMocks(); + canUseNativeHostCapabilityMock.mockReturnValue(false); + importHostAudioFileMock.mockResolvedValue(false); + importHostImageFileMock.mockResolvedValue(false); +}); + test('visual novel profile tab uses PlatformSubpanel shells', () => { const { container } = render( {}} />, @@ -283,6 +305,134 @@ test('visual novel result uploads scene and character assets into platform refer ).toContain('/generated-custom-world-scenes/'); }); +test('visual novel result imports scene image through native host before platform upload', async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(); + const visualNovelCreation = await import( + '../../services/visual-novel-creation' + ); + const uploadMock = vi.mocked(visualNovelCreation.uploadVisualNovelAsset); + + canUseNativeHostCapabilityMock.mockImplementation( + (capability) => capability === 'file.importImage', + ); + importHostImageFileMock.mockResolvedValue({ + action: 'selected', + fileName: 'native-scene.png', + mimeType: 'image/png', + base64Data: 'bmF0aXZlLWltYWdl', + bytes: 12, + }); + uploadMock.mockResolvedValue({ + assetObjectId: 'asset-scene-native', + assetKind: 'scene_image', + objectKey: 'generated-custom-world-scenes/native-scene.png', + imageSrc: '/generated-custom-world-scenes/native-scene.png', + }); + + render( + {}} + onSaveDraft={onSaveDraft} + />, + ); + + await user.click(screen.getByRole('button', { name: '场景' })); + await user.click(screen.getByRole('button', { name: /风雪站台/u })); + + const dialog = screen.getByRole('dialog', { name: '风雪站台' }); + await user.click( + within(dialog).getAllByRole('button', { name: '背景图' })[0]!, + ); + await user.click( + within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', { + name: '上传', + }), + ); + + expect(importHostImageFileMock).toHaveBeenCalledTimes(1); + expect(uploadMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'scene_background', + file: expect.any(File), + }), + ); + const uploadedFile = uploadMock.mock.calls[0]?.[0].file; + expect(uploadedFile?.name).toBe('native-scene.png'); + expect(uploadedFile?.type).toBe('image/png'); + + await user.click(within(dialog).getByRole('button', { name: '关闭' })); + await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!); + + expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe( + '/generated-custom-world-scenes/native-scene.png', + ); +}); + +test('visual novel result imports scene audio through native host before platform upload', async () => { + const user = userEvent.setup(); + const onSaveDraft = vi.fn(); + const visualNovelCreation = await import( + '../../services/visual-novel-creation' + ); + const uploadMock = vi.mocked(visualNovelCreation.uploadVisualNovelAsset); + + canUseNativeHostCapabilityMock.mockImplementation( + (capability) => capability === 'file.importAudio', + ); + importHostAudioFileMock.mockResolvedValue({ + action: 'selected', + fileName: 'native-music.webm', + mimeType: 'audio/webm', + base64Data: 'bmF0aXZlLWF1ZGlv', + bytes: 12, + }); + uploadMock.mockResolvedValue({ + assetObjectId: 'asset-audio-native', + assetKind: 'music', + objectKey: 'generated-custom-world-scenes/native-music.webm', + imageSrc: '/generated-custom-world-scenes/native-music.webm', + }); + + render( + {}} + onSaveDraft={onSaveDraft} + />, + ); + + await user.click(screen.getByRole('button', { name: '场景' })); + await user.click(screen.getByRole('button', { name: /风雪站台/u })); + + const dialog = screen.getByRole('dialog', { name: '风雪站台' }); + await user.click(within(dialog).getByRole('button', { name: '音乐' })); + await user.click( + within(screen.getByRole('dialog', { name: '音乐' })).getByRole('button', { + name: '上传', + }), + ); + + expect(importHostAudioFileMock).toHaveBeenCalledTimes(1); + expect(uploadMock).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'music', + file: expect.any(File), + }), + ); + const uploadedFile = uploadMock.mock.calls[0]?.[0].file; + expect(uploadedFile?.name).toBe('native-music.webm'); + expect(uploadedFile?.type).toBe('audio/webm'); + + await user.click(within(dialog).getByRole('button', { name: '关闭' })); + await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!); + + expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.musicSrc).toBe( + '/generated-custom-world-scenes/native-music.webm', + ); +}); + test('visual novel result generates scene background from asset picker', async () => { const user = userEvent.setup(); const onSaveDraft = vi.fn(); diff --git a/src/components/visual-novel-result/VisualNovelResultView.tsx b/src/components/visual-novel-result/VisualNovelResultView.tsx index 05a507978..673c89881 100644 --- a/src/components/visual-novel-result/VisualNovelResultView.tsx +++ b/src/components/visual-novel-result/VisualNovelResultView.tsx @@ -26,6 +26,11 @@ import type { VisualNovelValidationIssue, } from '../../../packages/shared/src/contracts/visualNovel'; import { resolveAssetReadUrl } from '../../services/assetReadUrlService'; +import { + canUseNativeHostCapability, + importHostAudioFile, + importHostImageFile, +} from '../../services/host-bridge/hostBridge'; import { buildVisualNovelImageGenerationPrompt, createVisualNovelBackgroundMusicTask, @@ -398,6 +403,19 @@ function formatHistoryAssetDate(value: string | undefined) { }); } +function visualNovelBase64DataToFile( + base64Data: string, + fileName: string, + mimeType: string, +) { + const binary = atob(base64Data); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new File([bytes], fileName, { type: mimeType }); +} + function VisualNovelAssetPickerDialog({ config, disabled, @@ -420,6 +438,11 @@ function VisualNovelAssetPickerDialog({ const [isUploading, setIsUploading] = useState(false); const [isGeneratingImage, setIsGeneratingImage] = useState(false); const [error, setError] = useState(null); + const canImportHostImage = + config.previewTone === 'image' && canUseNativeHostCapability('file.importImage'); + const canImportHostAudio = + config.previewTone === 'audio' && canUseNativeHostCapability('file.importAudio'); + const canImportHostAsset = canImportHostImage || canImportHostAudio; useEffect(() => { if (!config.historyKind) { @@ -498,6 +521,22 @@ function VisualNovelAssetPickerDialog({ } }; + const uploadFile = async (file: File) => { + if (!file) { + return null; + } + + const asset = await uploadVisualNovelAsset({ + kind: config.uploadKind, + file, + ownerUserId: authUi?.user?.id ?? null, + profileId: config.profileId ?? null, + entityId: config.entityId ?? null, + }); + onSelect(asset); + return asset; + }; + const handleUpload = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.currentTarget.value = ''; @@ -508,14 +547,40 @@ function VisualNovelAssetPickerDialog({ setIsUploading(true); setError(null); try { - const asset = await uploadVisualNovelAsset({ - kind: config.uploadKind, - file, - ownerUserId: authUi?.user?.id ?? null, - profileId: config.profileId ?? null, - entityId: config.entityId ?? null, - }); - onSelect(asset); + await uploadFile(file); + } catch (uploadError) { + setError( + uploadError instanceof Error + ? uploadError.message + : '平台素材上传失败。', + ); + } finally { + setIsUploading(false); + } + }; + + const handleUploadAction = async () => { + if (!canImportHostAsset) { + fileInputRef.current?.click(); + return; + } + + setIsUploading(true); + setError(null); + try { + const importedAsset = canImportHostImage + ? await importHostImageFile() + : await importHostAudioFile(); + if (!importedAsset) { + return; + } + await uploadFile( + visualNovelBase64DataToFile( + importedAsset.base64Data, + importedAsset.fileName, + importedAsset.mimeType, + ), + ); } catch (uploadError) { setError( uploadError instanceof Error @@ -541,7 +606,9 @@ function VisualNovelAssetPickerDialog({ fileInputRef.current?.click()} + onClick={() => { + void handleUploadAction(); + }} className="min-h-10" > {isUploading ? (