From ba449f4681bbf4baa96b918dd71c2cb0394428ac Mon Sep 17 00:00:00 2001
From: hzj <1304805162@qq.com>
Date: Mon, 27 Apr 2026 19:04:37 +0800
Subject: [PATCH] =?UTF-8?q?feat(=E6=96=B0=E7=BB=84=E4=BB=B6):=20vap?=
=?UTF-8?q?=E8=A7=86=E9=A2=91=E6=92=AD=E6=94=BE=E5=99=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package.json | 1 +
src/components/VapMp4Player.vue | 245 ++++++++++++++++++++++++++++++++
src/utils/vapMp4Config.js | 151 ++++++++++++++++++++
3 files changed, 397 insertions(+)
create mode 100644 src/components/VapMp4Player.vue
create mode 100644 src/utils/vapMp4Config.js
diff --git a/package.json b/package.json
index 94f1ed3..bc8ea2e 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,7 @@
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vant": "^4.9.21",
+ "video-animation-player": "^1.0.5",
"vue": "^3.5.18",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
diff --git a/src/components/VapMp4Player.vue b/src/components/VapMp4Player.vue
new file mode 100644
index 0000000..431bb69
--- /dev/null
+++ b/src/components/VapMp4Player.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+
+
+
Loading...
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
diff --git a/src/utils/vapMp4Config.js b/src/utils/vapMp4Config.js
new file mode 100644
index 0000000..80d5c10
--- /dev/null
+++ b/src/utils/vapMp4Config.js
@@ -0,0 +1,151 @@
+const VAP_CONFIG_BOX = 'vapc'
+const TEXT_DECODER = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8') : null
+
+const configCache = new Map()
+const containerBoxes = new Set([
+ 'moov',
+ 'trak',
+ 'mdia',
+ 'minf',
+ 'stbl',
+ 'edts',
+ 'udta',
+ 'meta',
+ 'ilst',
+ 'moof',
+ 'traf',
+ 'mvex',
+ 'mfra',
+])
+
+function readBoxType(view, offset) {
+ return String.fromCharCode(
+ view.getUint8(offset),
+ view.getUint8(offset + 1),
+ view.getUint8(offset + 2),
+ view.getUint8(offset + 3)
+ )
+}
+
+function readBoxSize(view, offset, end) {
+ const smallSize = view.getUint32(offset)
+
+ if (smallSize === 0) {
+ return { size: end - offset, headerSize: 8 }
+ }
+
+ if (smallSize === 1) {
+ const high = view.getUint32(offset + 8)
+ const low = view.getUint32(offset + 12)
+ return { size: high * 2 ** 32 + low, headerSize: 16 }
+ }
+
+ return { size: smallSize, headerSize: 8 }
+}
+
+function findBoxPayload(buffer, boxName, start = 0, end = buffer.byteLength) {
+ const view = new DataView(buffer)
+ let offset = start
+
+ while (offset + 8 <= end) {
+ const { size, headerSize } = readBoxSize(view, offset, end)
+ const type = readBoxType(view, offset + 4)
+
+ if (size < headerSize || offset + size > end) {
+ break
+ }
+
+ const payloadStart = offset + headerSize
+ const payloadEnd = offset + size
+
+ if (type === boxName) {
+ return buffer.slice(payloadStart, payloadEnd)
+ }
+
+ if (containerBoxes.has(type)) {
+ const childStart = type === 'meta' ? payloadStart + 4 : payloadStart
+ const payload = findBoxPayload(buffer, boxName, childStart, payloadEnd)
+ if (payload) {
+ return payload
+ }
+ }
+
+ offset += size
+ }
+
+ return null
+}
+
+function decodeUtf8(buffer) {
+ if (TEXT_DECODER) {
+ return TEXT_DECODER.decode(buffer)
+ }
+
+ const bytes = new Uint8Array(buffer)
+ let binary = ''
+
+ for (let i = 0; i < bytes.length; i += 1) {
+ binary += String.fromCharCode(bytes[i])
+ }
+
+ return decodeURIComponent(escape(binary))
+}
+
+function parseJsonPayload(payload) {
+ const rawText = decodeUtf8(payload).replace(/^\uFEFF/, '').replace(/\0+$/g, '').trim()
+ const jsonStart = rawText.search(/[\[{]/)
+
+ if (jsonStart < 0) {
+ throw new Error('vapc box does not contain json')
+ }
+
+ return JSON.parse(rawText.slice(jsonStart))
+}
+
+export async function getVapConfigFromMp4(mp4Url) {
+ if (!mp4Url) {
+ throw new Error('mp4 url is required')
+ }
+
+ if (configCache.has(mp4Url)) {
+ return configCache.get(mp4Url)
+ }
+
+ const configPromise = fetch(mp4Url)
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`mp4 request failed: ${response.status}`)
+ }
+
+ return response.arrayBuffer()
+ })
+ .then((buffer) => {
+ const payload = findBoxPayload(buffer, VAP_CONFIG_BOX)
+
+ if (!payload) {
+ throw new Error('vapc box not found')
+ }
+
+ return parseJsonPayload(payload)
+ })
+ .catch((error) => {
+ configCache.delete(mp4Url)
+ throw error
+ })
+
+ configCache.set(mp4Url, configPromise)
+ return configPromise
+}
+
+export function preloadVapConfig(mp4Url) {
+ return getVapConfigFromMp4(mp4Url).catch(() => null)
+}
+
+export function clearVapConfigCache(mp4Url) {
+ if (mp4Url) {
+ configCache.delete(mp4Url)
+ return
+ }
+
+ configCache.clear()
+}