player.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import test from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { readFile } from 'node:fs/promises'
  4. import { createVideoPlayer } from '../../src/modules/cockpit/services/videoPlayer.js'
  5. const settle = () => new Promise((resolve) => setImmediate(resolve))
  6. const offerSdp = 'v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=recvonly\r\n'
  7. const answerSdp = 'v=0\r\no=- 2 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=sendonly\r\n'
  8. const signalingUrl = 'https://media.test/index/api/webrtc?app=rtp&stream=camera%2F01&type=play'
  9. const answer = () => new Response(JSON.stringify({ code: 0, type: 'answer', sdp: answerSdp }), {
  10. headers: { 'Content-Type': 'application/json' },
  11. })
  12. class Peer extends EventTarget {
  13. static instances = []
  14. constructor(configuration) {
  15. super()
  16. this.configuration = configuration
  17. this.iceGatheringState = 'new'
  18. this.connectionState = 'new'
  19. this.receivers = []
  20. this.transceivers = []
  21. Peer.instances.push(this)
  22. }
  23. addTransceiver(kind, options) { this.transceivers.push({ kind, ...options }) }
  24. async createOffer() { return { type: 'offer', sdp: offerSdp } }
  25. async setLocalDescription(description) { this.localDescription = description; this.iceGatheringState = 'gathering' }
  26. async setRemoteDescription(description) { this.remoteDescription = description }
  27. gather() {
  28. this.localDescription = { type: 'offer', sdp: offerSdp + 'a=candidate:1 1 udp 1 192.0.2.1 5000 typ host\r\n' }
  29. this.iceGatheringState = 'complete'
  30. this.dispatchEvent(new Event('icegatheringstatechange'))
  31. }
  32. receive(track, streamless = false) {
  33. this.receivers.push({ track })
  34. const stream = { getTracks: () => [track] }
  35. this.dispatchEvent(Object.assign(new Event('track'), { track, streams: streamless ? [] : [stream] }))
  36. return stream
  37. }
  38. getReceivers() { return this.receivers }
  39. close() { this.connectionState = 'closed' }
  40. }
  41. class Clock {
  42. now = 0
  43. next = 1
  44. tasks = new Map()
  45. setTimeout(callback, delay) { const id = this.next++; this.tasks.set(id, { at: this.now + delay, callback }); return id }
  46. clearTimeout(id) { this.tasks.delete(id) }
  47. advance(ms) {
  48. const end = this.now + ms
  49. while (true) {
  50. const entry = [...this.tasks].sort((a, b) => a[1].at - b[1].at).find(([, item]) => item.at <= end)
  51. if (!entry) break
  52. const [id, item] = entry
  53. this.tasks.delete(id); this.now = item.at; item.callback()
  54. }
  55. this.now = end
  56. }
  57. }
  58. class Track extends EventTarget {
  59. kind = 'video'
  60. readyState = 'live'
  61. stop() { this.readyState = 'ended' }
  62. }
  63. class Video extends EventTarget {
  64. constructor(protocol = 'https:') {
  65. super()
  66. this.ownerDocument = { location: { protocol } }
  67. this.attributes = new Map()
  68. this.srcObject = null
  69. this.paused = true
  70. this.loads = 0
  71. }
  72. set src(value) { this.setAttribute('src', value) }
  73. get src() { return this.getAttribute('src') || '' }
  74. setAttribute(name, value) { this.attributes.set(name, String(value)) }
  75. getAttribute(name) { return this.attributes.get(name) ?? null }
  76. removeAttribute(name) { this.attributes.delete(name) }
  77. play() { this.paused = false; return Promise.resolve() }
  78. pause() { this.paused = true }
  79. load() { this.loads++ }
  80. fire(type) { this.dispatchEvent(new Event(type)) }
  81. }
  82. // 已确认接缝:真实driver + HTMLMediaElement/Peer/HTTP/时钟的外部边界替身。
  83. // 只观察媒体元素、回调、信令合同与本地资源释放,不读取driver私有状态。
  84. test('原生视频仅在playing后就绪,销毁时清空媒体且不再报告迟到事件', async () => {
  85. const video = new Video()
  86. const ready = [], errors = []
  87. const player = createVideoPlayer(video, { kind: 'native', url: 'https://media.test/live.mp4' }, {
  88. onReady: () => ready.push('画面就绪'), onError: (error) => errors.push(error),
  89. })
  90. await settle()
  91. assert.equal(video.src, 'https://media.test/live.mp4')
  92. assert.equal(video.muted, true)
  93. video.fire('loadedmetadata')
  94. assert.deepEqual(ready, [])
  95. video.fire('playing')
  96. assert.deepEqual(ready, ['画面就绪'])
  97. player.destroy()
  98. player.destroy()
  99. assert.equal(video.src, '')
  100. assert.equal(video.srcObject, null)
  101. assert.equal(video.paused, true)
  102. video.fire('playing')
  103. video.fire('error')
  104. assert.deepEqual(ready, ['画面就绪'])
  105. assert.deepEqual(errors, [])
  106. })
  107. test('协议、凭据、混合内容和非原生媒体地址在触达媒体前被拒绝', async () => {
  108. const invalid = [
  109. { kind: 'native', url: 'javascript:alert(1)' },
  110. { kind: 'native', url: 'webrtc://media.test/live' },
  111. { kind: 'native', url: '/live.mp4' },
  112. { kind: 'native', url: 'https://user:secret@media.test/live.mp4' },
  113. { kind: 'native', url: 'http://media.test/live.mp4' },
  114. { kind: 'native', url: 'https://media.test/live.flv?token=secret' },
  115. { kind: 'native', url: 'https://media.test/index/api/webrtc?app=live&type=play' },
  116. { kind: 'unknown', url: 'https://media.test/live.mp4' },
  117. ]
  118. for (const media of invalid) {
  119. const video = new Video(), errors = []
  120. const player = createVideoPlayer(video, media, { onError: (error) => errors.push(error) })
  121. await settle()
  122. assert.equal(video.src, '')
  123. assert.equal(errors.length, 1)
  124. assert.equal(errors[0].code, 'VIDEO_URL_INVALID')
  125. assert.doesNotMatch(errors[0].message, /secret|media\.test/)
  126. player.destroy()
  127. }
  128. })
  129. test('WebRTC收集ICE后发送实际ZLM SDP,接收轨道且playing才就绪', async () => {
  130. const video = new Video(), requests = [], ready = [], errors = []
  131. const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
  132. PeerConnection: Peer,
  133. fetchImpl: async (url, init) => { requests.push({ url, ...init }); return answer() },
  134. onReady: () => ready.push('已播放'), onError: (error) => errors.push(error),
  135. })
  136. await settle()
  137. const peer = Peer.instances.at(-1)
  138. assert.ok(peer, '通过浏览器Peer边界建立连接')
  139. assert.deepEqual(peer.configuration?.iceServers || [], [])
  140. assert.deepEqual(peer.transceivers, [{ kind: 'video', direction: 'recvonly' }, { kind: 'audio', direction: 'recvonly' }])
  141. assert.equal(requests.length, 0, '不提交尚未收集完的offer')
  142. assert.equal(video.src, '', '信令地址不得进入video src')
  143. peer.gather()
  144. await settle()
  145. assert.equal(requests.length, 1)
  146. assert.equal(requests[0].url, signalingUrl)
  147. assert.equal(requests[0].method, 'POST')
  148. assert.equal(new Headers(requests[0].headers).get('content-type'), 'text/plain')
  149. assert.equal(new Headers(requests[0].headers).get('authorization'), null)
  150. assert.equal(requests[0].credentials, 'omit')
  151. assert.equal(requests[0].redirect, 'error')
  152. assert.match(requests[0].body, /a=candidate:1/)
  153. assert.deepEqual(peer.remoteDescription, { type: 'answer', sdp: answerSdp })
  154. const track = new Track(), stream = peer.receive(track)
  155. await settle()
  156. assert.equal(video.srcObject, stream)
  157. assert.deepEqual(ready, [])
  158. video.fire('playing')
  159. assert.deepEqual(ready, ['已播放'])
  160. player.destroy()
  161. assert.equal(peer.connectionState, 'closed')
  162. assert.equal(track.readyState, 'ended')
  163. assert.equal(video.srcObject, null)
  164. assert.deepEqual(errors, [])
  165. assert.equal(requests.length, 1, '本地销毁不调用后端stop或重连')
  166. })
  167. test('ICE收集最多等待5秒,超时关闭Peer且不发出不完整信令', async () => {
  168. const video = new Video(), clock = new Clock(), errors = [], requests = []
  169. const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
  170. PeerConnection: Peer, timers: clock,
  171. fetchImpl: async (...args) => { requests.push(args); return answer() },
  172. onError: (error) => errors.push(error),
  173. })
  174. await settle()
  175. const peer = Peer.instances.at(-1)
  176. clock.advance(4999)
  177. assert.equal(errors.length, 0)
  178. clock.advance(1)
  179. await settle()
  180. assert.equal(errors.length, 1)
  181. assert.equal(errors[0].code, 'VIDEO_ICE_TIMEOUT')
  182. assert.equal(peer.connectionState, 'closed')
  183. assert.deepEqual(requests, [])
  184. player.destroy()
  185. })
  186. test('媒体错误、轨道结束与Peer断流会释放本地资源并只报一次失败', async () => {
  187. for (const failure of ['video-error', 'video-ended', 'track-ended', 'peer-failed', 'peer-disconnected', 'ice-failed']) {
  188. const video = new Video(), errors = [], ready = [], requests = []
  189. const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
  190. PeerConnection: Peer,
  191. fetchImpl: async (...args) => { requests.push(args); return answer() },
  192. onError: (error) => errors.push(error), onReady: () => ready.push('已播放'),
  193. })
  194. await settle()
  195. const peer = Peer.instances.at(-1)
  196. peer.gather(); await settle()
  197. const track = new Track()
  198. peer.receive(track); await settle()
  199. video.fire('playing')
  200. if (failure.startsWith('video-')) video.fire(failure.slice(6))
  201. if (failure === 'track-ended') track.dispatchEvent(new Event('ended'))
  202. if (failure.startsWith('peer-')) {
  203. peer.connectionState = failure.slice(5)
  204. peer.dispatchEvent(new Event('connectionstatechange'))
  205. }
  206. if (failure === 'ice-failed') {
  207. peer.iceConnectionState = 'failed'
  208. peer.dispatchEvent(new Event('iceconnectionstatechange'))
  209. }
  210. await settle()
  211. assert.equal(errors.length, 1, failure)
  212. assert.equal(peer.connectionState, 'closed', failure)
  213. assert.equal(track.readyState, 'ended', failure)
  214. assert.equal(video.srcObject, null, failure)
  215. video.fire('error'); video.fire('playing')
  216. assert.equal(errors.length, 1)
  217. assert.deepEqual(ready, ['已播放'])
  218. assert.equal(requests.length, 1)
  219. player.destroy()
  220. }
  221. })
  222. test('销毁中的ICE和迟到信令不会再挂轨道、报就绪或请求后端停止', async () => {
  223. const clock = new Clock(), errors = [], requests = []
  224. const first = createVideoPlayer(new Video(), { kind: 'webrtc', url: signalingUrl }, {
  225. PeerConnection: Peer, timers: clock, onError: (error) => errors.push(error),
  226. fetchImpl: async (...args) => { requests.push(args); return answer() },
  227. })
  228. await settle()
  229. const waiting = Peer.instances.at(-1)
  230. first.destroy(); clock.advance(10000); waiting.gather(); await settle()
  231. assert.equal(waiting.connectionState, 'closed')
  232. assert.equal(requests.length, 0)
  233. assert.equal(errors.length, 0)
  234. const video = new Video(), ready = []
  235. let resolveResponse
  236. const response = new Promise((resolve) => { resolveResponse = resolve })
  237. const second = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
  238. PeerConnection: Peer, onReady: () => ready.push(true), onError: (error) => errors.push(error),
  239. fetchImpl: (url, init) => { requests.push({ url, ...init }); return response },
  240. })
  241. await settle()
  242. const peer = Peer.instances.at(-1)
  243. peer.gather(); await settle()
  244. assert.equal(requests.length, 1)
  245. second.destroy()
  246. assert.equal(requests[0].signal.aborted, true)
  247. resolveResponse(answer()); await settle()
  248. assert.equal(peer.remoteDescription, undefined)
  249. assert.equal(video.srcObject, null)
  250. video.fire('playing')
  251. assert.deepEqual(ready, [])
  252. assert.deepEqual(errors, [])
  253. assert.equal(requests.length, 1)
  254. })
  255. test('HTML、非成功信令与非法answer不被视作可播放媒体', async () => {
  256. const responses = [
  257. () => new Response('<html>secret</html>', { headers: { 'Content-Type': 'text/html' } }),
  258. () => new Response('upstream secret', { status: 502 }),
  259. () => new Response(JSON.stringify({ code: 1, msg: 'secret' }), { headers: { 'Content-Type': 'application/json' } }),
  260. () => new Response(JSON.stringify({ code: 0, type: 'offer', sdp: answerSdp }), { headers: { 'Content-Type': 'application/json' } }),
  261. () => new Response(JSON.stringify({ code: 0, type: 'answer', sdp: '<html>secret</html>' }), { headers: { 'Content-Type': 'application/json' } }),
  262. () => new Response('null', { headers: { 'Content-Type': 'application/json' } }),
  263. () => { throw new Error('upstream secret at https://media.test/token') },
  264. ]
  265. for (const response of responses) {
  266. const video = new Video(), errors = [], ready = []
  267. const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
  268. PeerConnection: Peer, fetchImpl: async () => response(),
  269. onError: (error) => errors.push(error), onReady: () => ready.push(true),
  270. })
  271. await settle()
  272. const peer = Peer.instances.at(-1)
  273. peer.gather(); await settle()
  274. assert.equal(errors.length, 1)
  275. assert.equal(errors[0].code, 'VIDEO_SIGNALING_FAILED')
  276. assert.doesNotMatch(errors[0].message, /secret|media\.test|token/)
  277. assert.equal(peer.remoteDescription, undefined)
  278. assert.equal(peer.connectionState, 'closed')
  279. assert.equal(video.src, '')
  280. assert.deepEqual(ready, [])
  281. player.destroy()
  282. }
  283. })
  284. test('原生播放拒绝和缺失WebRTC能力均返回可清理的失败而非伪就绪', async () => {
  285. const video = new Video(), errors = [], ready = []
  286. video.play = () => Promise.reject(new Error('autoplay secret'))
  287. const player = createVideoPlayer(video, { kind: 'native', url: 'https://media.test/live.mp4' }, {
  288. onError: (error) => errors.push(error), onReady: () => ready.push(true),
  289. })
  290. await settle()
  291. assert.equal(errors[0].code, 'VIDEO_PLAYBACK_FAILED')
  292. assert.doesNotMatch(errors[0].message, /secret/)
  293. assert.equal(video.src, '')
  294. video.fire('playing')
  295. assert.deepEqual(ready, [])
  296. player.destroy()
  297. const unsupported = createVideoPlayer(new Video(), { kind: 'webrtc', url: signalingUrl }, {
  298. onError: (error) => errors.push(error),
  299. })
  300. await settle()
  301. assert.equal(errors[1].code, 'VIDEO_UNSUPPORTED')
  302. unsupported.destroy()
  303. })
  304. test('无关联stream的音视频轨道保留在同一媒体流,只有视频轨道可报画面就绪', async () => {
  305. class MediaStream {
  306. tracks = []
  307. addTrack(track) { this.tracks.push(track) }
  308. getTracks() { return this.tracks }
  309. }
  310. const video = new Video(), errors = [], ready = []
  311. video.ownerDocument.defaultView = { MediaStream }
  312. const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
  313. PeerConnection: Peer, fetchImpl: async () => answer(),
  314. onError: (error) => errors.push(error), onReady: () => ready.push('画面就绪'),
  315. })
  316. await settle()
  317. const peer = Peer.instances.at(-1)
  318. peer.gather(); await settle()
  319. const audio = new Track(), picture = new Track()
  320. audio.kind = 'audio'
  321. peer.receive(audio, true); await settle()
  322. assert.deepEqual(errors, [])
  323. video.fire('playing')
  324. assert.deepEqual(ready, [], '不能把只有音频当作视频画面就绪')
  325. peer.receive(picture, true); await settle()
  326. assert.deepEqual(video.srcObject.getTracks(), [audio, picture])
  327. video.fire('playing')
  328. assert.deepEqual(ready, ['画面就绪'])
  329. player.destroy()
  330. assert.equal(audio.readyState, 'ended')
  331. assert.equal(picture.readyState, 'ended')
  332. })
  333. test('Vue播放器公开media与ready接缝可挂载,换媒体和卸载清理旧播放', async () => {
  334. const { parseHTML } = await import('linkedom')
  335. const { window } = parseHTML('<html><body><div id="host"></div></body></html>')
  336. const previous = new Map()
  337. for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement']) {
  338. previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name))
  339. Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: window[name] })
  340. }
  341. // LinkeDOM不实现媒体播放;只在HTMLVideoElement外部边界提供播放/清理替身。
  342. const createElement = window.document.createElement.bind(window.document)
  343. window.document.createElement = (name, ...args) => {
  344. const element = createElement(name, ...args)
  345. if (name === 'video') {
  346. Object.defineProperty(element, 'src', {
  347. get: () => element.getAttribute('src') || '',
  348. set: (value) => element.setAttribute('src', value),
  349. })
  350. element.play = () => Promise.resolve()
  351. element.pause = () => { element.dataset.paused = 'true' }
  352. element.load = () => {}
  353. }
  354. return element
  355. }
  356. let app
  357. try {
  358. const { parse, compileScript } = await import('@vue/compiler-sfc')
  359. const file = new URL('../../src/modules/cockpit/components/VideoPlayer.vue', import.meta.url)
  360. const { descriptor, errors: parseErrors } = parse(await readFile(file, 'utf8'), { filename: file.pathname })
  361. assert.deepEqual(parseErrors, [])
  362. const compiled = compileScript(descriptor, { id: 'player-test', inlineTemplate: true })
  363. const code = compiled.content
  364. .replace(/from ["']vue["']/g, `from ${JSON.stringify(import.meta.resolve('vue'))}`)
  365. .replace(/from ["']\.\.\/services\/videoPlayer\.js["']/g,
  366. `from ${JSON.stringify(new URL('../../src/modules/cockpit/services/videoPlayer.js', import.meta.url).href)}`)
  367. const { default: Component } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
  368. const { createApp, h, shallowRef, nextTick } = await import('vue')
  369. const media = shallowRef({ kind: 'native', url: 'https://media.test/first.mp4' })
  370. const ready = [], errors = []
  371. app = createApp({ render: () => h(Component, {
  372. media: media.value, onReady: () => ready.push('ready'), onError: (error) => errors.push(error),
  373. }) })
  374. app.mount(window.document.querySelector('#host'))
  375. await nextTick(); await settle()
  376. const video = window.document.querySelector('video')
  377. assert.ok(video)
  378. assert.equal(video.src, 'https://media.test/first.mp4')
  379. assert.equal(video.hasAttribute('controls'), true)
  380. assert.match(video.getAttribute('controlslist'), /nodownload/)
  381. assert.equal(video.hasAttribute('disablepictureinpicture'), true)
  382. assert.equal(video.hasAttribute('disableremoteplayback'), true)
  383. video.dispatchEvent(new window.Event('playing'))
  384. assert.deepEqual(ready, ['ready'])
  385. media.value = { kind: 'native', url: 'https://media.test/second.mp4' }
  386. await nextTick(); await settle()
  387. assert.equal(video.src, 'https://media.test/second.mp4')
  388. assert.equal(video.dataset.paused, 'true')
  389. video.dispatchEvent(new window.Event('playing'))
  390. assert.deepEqual(ready, ['ready', 'ready'])
  391. app.unmount(); app = null
  392. assert.equal(video.getAttribute('src'), null)
  393. video.dispatchEvent(new window.Event('playing'))
  394. assert.deepEqual(ready, ['ready', 'ready'])
  395. assert.deepEqual(errors, [])
  396. } finally {
  397. app?.unmount()
  398. for (const [name, descriptor] of previous) {
  399. if (descriptor) Object.defineProperty(globalThis, name, descriptor)
  400. else delete globalThis[name]
  401. }
  402. }
  403. })