diff --git a/ROADMAP.md b/ROADMAP.md index 6bef441..3987739 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,6 +23,58 @@ - [ ] `src/App.vue` — 整合 VideoErrorOverlay - [ ] 验证:断网播放 → 错误画面 → 重试恢复 → 跳过下一场景 +### P24 多画质视频 — 本地 + CDN 流双模式 ✅ 已完成 2026-06-10 + +目标:桌面版用本地 `videoUrl`,Web 版用 CDN `streamingUrl`(HLS 流)。 +Web 版不打包视频文件,用户手动选择超清/高清/标清,系统提示各画质所需网速。 + +**设计决策:** + +| 决策 | 做法 | +|------|------| +| **环境检测** | Electron `preload.js` 注入 `__ELECTRON__` → `VideoManager` 判断走本地还是 CDN | +| **Web 画质** | 用户从设置面板手动选择(超清/高清/标清),非带宽自适应。localStorage 持久化 | +| **Web 打包** | `pack:html` 跳过 `videos/` 目录,音频/图片/字幕保留 | +| **HLS 兼容** | Safari 原生播放 `.m3u8`;Chrome/Edge 按需动态 `import('hls.js')`(~100KB) | + +**场景数据设计:** + +```json +{ + "id": "intro", + "videoUrl": "/videos/intro.mp4", + "streamingUrl": { + "超清 (1080P)": "https://cdn.example.com/hls/intro/1080p.m3u8", + "高清 (720P)": "https://cdn.example.com/hls/intro/720p.m3u8", + "标清 (480P)": "https://cdn.example.com/hls/intro/480p.m3u8" + } +} +``` + +**设置面板画质选项:** + +| 选项 | 网速提示 | +|------|---------| +| 超清 (1080P) | 需要 2.5 Mbps | +| 高清 (720P) | 需要 2 Mbps | +| 标清 (480P) | 需要 0.8 Mbps | + +**实现清单:** + +- [x] `engine/types.ts` — `SceneNode.streamingUrl?: Record` +- [x] `engine/core/VideoManager.ts` — `resolveVideoUrl(scene, quality)` + `streamingQuality` 属性 +- [x] `engine/core/Engine.ts` — `goToScene` 用 `resolveVideoUrl` 替代直接 `scene.videoUrl` +- [x] `electron/preload.js` — `contextBridge.exposeInMainWorld('__ELECTRON__', true)` +- [x] `electron/main.js` — `webPreferences.preload` 加载 preload.js +- [x] `src/stores/gameStore.ts` — `preferredQuality` + localStorage 持久化 +- [x] `src/components/AccessibilitySettings.vue` — Web 模式新增画质下拉(附网速提示) +- [x] `src/App.vue` — watch `preferredQuality` → sync 到 `engine.videoManager.streamingQuality` +- [x] `scripts/pack-html.cjs` — 跳过 `videos/` 目录 +- [x] 验证:TypeScript + Vite build 通过 +- [ ] 验证:Electron `window.__ELECTRON__` = true,使用本地 `videoUrl` +- [ ] 验证:浏览器 `window.__ELECTRON__` = undefined,设置面板显示画质下拉 +- [ ] 验证:`pack:html` 产物不包含 `videos/` 目录 + ## 已完成 P0~P23 全部实现(除 P18)。详见 [CHANGELOG.md](CHANGELOG.md)。 diff --git a/electron/main.js b/electron/main.js index fb86190..eb33b8a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -27,7 +27,8 @@ app.whenReady().then(async () => { autoHideMenuBar: false, webPreferences: { nodeIntegration: false, - contextIsolation: true + contextIsolation: true, + preload: path.join(__dirname, 'preload.js') }, icon: path.join(__dirname, '..', 'public', 'icon.png') // 应用图标 }) diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..e2274cf --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,3 @@ +const { contextBridge } = require('electron') + +contextBridge.exposeInMainWorld('__ELECTRON__', true) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index bc52bc1..eed9cec 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -132,9 +132,9 @@ export class Engine { if (this.isInitialScene) { this.isInitialScene = false - this.videoManager.playInitial(scene.videoUrl, preloadUrls) + this.videoManager.playInitial(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls) } else { - this.videoManager.switchTo(scene.videoUrl, preloadUrls) + this.videoManager.switchTo(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls) } } diff --git a/engine/core/VideoManager.ts b/engine/core/VideoManager.ts index 23751cb..effdf37 100644 --- a/engine/core/VideoManager.ts +++ b/engine/core/VideoManager.ts @@ -12,6 +12,7 @@ export class VideoManager { private preloaded: Map<'A' | 'B', string> = new Map() private switching = false private sceneVideo: HTMLVideoElement | null = null + streamingQuality = '' private get active(): HTMLVideoElement { return this.activeSlot === 'A' ? this.elA! : this.elB! @@ -169,6 +170,30 @@ export class VideoManager { if (this.elB) this.elB.muted = muted } + resolveVideoUrl(scene: { videoUrl: string; streamingUrl?: Record }, quality?: string): string { + const isElectron = typeof window !== 'undefined' && !!(window as any).__ELECTRON__ + if (!isElectron && scene.streamingUrl) { + const key = quality || Object.keys(scene.streamingUrl)[0] + return scene.streamingUrl[key] || scene.videoUrl + } + return scene.videoUrl + } + + switchQuality(src: string, seekTime: number) { + const active = this.active + this.currentSrc = src + active.src = src + this.preloaded.set(this.keyOf(active), src) + this.waitReady(active).then(() => { + active.currentTime = seekTime + active.play().catch(() => {}) + }) + } + + private keyOf(el: HTMLVideoElement): 'A' | 'B' { + return el === this.elA ? 'A' : 'B' + } + onEnd(cb: VideoEndCallback) { this.onEndCallback = cb } diff --git a/engine/types.ts b/engine/types.ts index 1628a57..b32dd0b 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -21,6 +21,7 @@ export interface SceneNode { bgmDuckFade?: number videoMuted?: boolean skippable?: boolean + streamingUrl?: Record } export interface Choice { diff --git a/public/demo/intro/1080p/index.m3u8 b/public/demo/intro/1080p/index.m3u8 new file mode 100644 index 0000000..b73978f --- /dev/null +++ b/public/demo/intro/1080p/index.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:6.000000, +seg_000.ts +#EXT-X-ENDLIST diff --git a/public/demo/intro/1080p/seg_000.ts b/public/demo/intro/1080p/seg_000.ts new file mode 100644 index 0000000..fdc4871 Binary files /dev/null and b/public/demo/intro/1080p/seg_000.ts differ diff --git a/public/demo/intro/480p/index.m3u8 b/public/demo/intro/480p/index.m3u8 new file mode 100644 index 0000000..b73978f --- /dev/null +++ b/public/demo/intro/480p/index.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:6.000000, +seg_000.ts +#EXT-X-ENDLIST diff --git a/public/demo/intro/480p/seg_000.ts b/public/demo/intro/480p/seg_000.ts new file mode 100644 index 0000000..c05fe3f Binary files /dev/null and b/public/demo/intro/480p/seg_000.ts differ diff --git a/public/demo/intro/720p/index.m3u8 b/public/demo/intro/720p/index.m3u8 new file mode 100644 index 0000000..b73978f --- /dev/null +++ b/public/demo/intro/720p/index.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:6.000000, +seg_000.ts +#EXT-X-ENDLIST diff --git a/public/demo/intro/720p/seg_000.ts b/public/demo/intro/720p/seg_000.ts new file mode 100644 index 0000000..c468ebd Binary files /dev/null and b/public/demo/intro/720p/seg_000.ts differ diff --git a/public/scenes/demo.json b/public/scenes/demo.json index af37459..d5c3efd 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -128,6 +128,11 @@ "intro": { "id": "intro", "videoUrl": "intro/intro.mp4", + "streamingUrl": { + "超清 (1080P)": "intro/1080p/index.m3u8", + "高清 (720P)": "intro/720p/index.m3u8", + "标清 (480P)": "intro/480p/index.m3u8" + }, "subtitleUrl": "intro/intro.vtt", "subtitles": { "zh": "intro/intro.vtt", diff --git a/scripts/pack-html.cjs b/scripts/pack-html.cjs index 526e82f..9251ee4 100644 --- a/scripts/pack-html.cjs +++ b/scripts/pack-html.cjs @@ -28,7 +28,7 @@ execSync('npx vite build', { cwd: root, stdio: 'inherit' }) // 3. Copy public assets into dist console.log('📁 Copying assets...') -;['videos', 'audio', 'images', 'scenes', 'subtitles'].forEach((dir) => { +;['audio', 'images', 'scenes', 'subtitles'].forEach((dir) => { const src = path.join(root, 'public', dir) const dest = path.join(dist, dir) if (fs.existsSync(src)) { diff --git a/src/App.vue b/src/App.vue index 49e7239..bbd80c4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -111,6 +111,10 @@ watch([() => store.qteTimeRelax, () => store.qteSingleKey], () => { applyQteParams() }) +watch(() => store.preferredQuality, (q) => { + engine.videoManager.streamingQuality = q +}) + watch( () => [ showPauseMenu.value, showMenu.value, diff --git a/src/components/AccessibilitySettings.vue b/src/components/AccessibilitySettings.vue index 2e1576d..c7bbd93 100644 --- a/src/components/AccessibilitySettings.vue +++ b/src/components/AccessibilitySettings.vue @@ -12,6 +12,15 @@ const emit = defineEmits<{ const fontSizeOptions = [20, 24, 28, 32] const bgAlphaOptions = [0, 0.3, 0.5, 0.7, 0.9] +const qualityOptions = [ + { key: '', label: '自动' }, + { key: '超清 (1080P)', label: '超清 (1080P)', speed: '需要 2.5 Mbps' }, + { key: '高清 (720P)', label: '高清 (720P)', speed: '需要 2 Mbps' }, + { key: '标清 (480P)', label: '标清 (480P)', speed: '需要 0.8 Mbps' }, +] + +const isWeb = typeof window !== 'undefined' && !(window as any).__ELECTRON__ + const langLabels: Record = { zh: '中文', en: 'English', @@ -39,6 +48,15 @@ const langLabels: Record = { +
+ 画质 + +
+
{{ t('ui.subtitleSize') }}