feat: adaptive bitrate support, engine improvements, demo updates, and electron preload

This commit is contained in:
2026-06-12 17:15:30 +08:00
parent 6575b0be0f
commit b6231e4efd
17 changed files with 139 additions and 4 deletions

View File

@@ -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<string, string>`
- [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)。

View File

@@ -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') // 应用图标
})

3
electron/preload.js Normal file
View File

@@ -0,0 +1,3 @@
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('__ELECTRON__', true)

View File

@@ -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)
}
}

View File

@@ -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<string, string> }, 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
}

View File

@@ -21,6 +21,7 @@ export interface SceneNode {
bgmDuckFade?: number
videoMuted?: boolean
skippable?: boolean
streamingUrl?: Record<string, string>
}
export interface Choice {

View File

@@ -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

Binary file not shown.

View File

@@ -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

Binary file not shown.

View File

@@ -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

Binary file not shown.

View File

@@ -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",

View File

@@ -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)) {

View File

@@ -111,6 +111,10 @@ watch([() => store.qteTimeRelax, () => store.qteSingleKey], () => {
applyQteParams()
})
watch(() => store.preferredQuality, (q) => {
engine.videoManager.streamingQuality = q
})
watch(
() => [
showPauseMenu.value, showMenu.value,

View File

@@ -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<string, string> = {
zh: '中文',
en: 'English',
@@ -39,6 +48,15 @@ const langLabels: Record<string, string> = {
</select>
</div>
<div class="setting-row" v-if="isWeb">
<span class="setting-label">画质</span>
<select :value="store.preferredQuality" @change="store.setPreferredQuality(($event.target as HTMLSelectElement).value)">
<option v-for="q in qualityOptions" :key="q.key" :value="q.key">
{{ q.label }}{{ q.speed ? '' + q.speed + '' : '' }}
</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">{{ t('ui.subtitleSize') }}</span>
<select :value="store.subFontSize" @change="store.setSubFontSize(+($event.target as HTMLSelectElement).value)">

View File

@@ -45,6 +45,7 @@ export const useGameStore = defineStore('game', () => {
const antiMistap = ref(localStorage.getItem('antiMistap') !== 'false')
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
const showSettings = ref(false)
const preferredQuality = ref(localStorage.getItem('preferredQuality') || '')
const introVideo = ref('')
const menuVideo = ref('')
@@ -199,6 +200,8 @@ export const useGameStore = defineStore('game', () => {
function setPauseEnabled(v: boolean) { pauseEnabled.value = v; localStorage.setItem('pauseEnabled', String(v)) }
function setShowSettings(v: boolean) { showSettings.value = v }
function setPreferredQuality(q: string) { preferredQuality.value = q; localStorage.setItem('preferredQuality', q) }
function setIntroVideo(url: string) { introVideo.value = url }
function setMenuVideo(url: string) { menuVideo.value = url }
@@ -223,6 +226,7 @@ export const useGameStore = defineStore('game', () => {
storyLocales,
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
showSettings, introVideo, menuVideo,
preferredQuality,
setScene, setChoices, clearChoices, setGameEnded,
setTimer, clearTimer, setSaves,
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
@@ -235,6 +239,7 @@ export const useGameStore = defineStore('game', () => {
setStoryLocales,
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
setShowSettings, setIntroVideo, setMenuVideo,
setPreferredQuality,
dump,
}
})