fix(canvas): [PKG-CANVAS-SUBTITLE-01] attach native subtitle tracks

This commit is contained in:
Harvey Zhao committed 2026-09-14 18:51:57 +08:00
1 parent d89728c0a6
commit c3967bcbf4
23 files changed
+1459 -11

No files matched your search

+6
View File
@@ -1,5 +1,11 @@
# Browser regression entry
`canvas-subtitles.spec.js` covers native track bootstrap/load/seek/replacement and
cleanup on old/new cores, including ordinary Canvas child operations. It is part
of both browser scopes. `ARTPLAYER_CANVAS_SUBTITLE_BASELINE=1.1.0` runs the exact
published proxy as a failing historical control; it cannot be combined with an
installed candidate map. This is a real media/track test, not a simulated cue list.
Chapter pointer geometry is shared by `chapter-hover.ts` and the combination
suite. It awaits locator actionability with `hover({ trial: true })` before
reading coordinates; `art.fullscreen` can become true while controls still move.
+68
View File
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { canvasCandidate, canvasHistorical } from '../helpers/canvas.js'
import { expect, test } from './fixtures.js'
const baseline = process.env.ARTPLAYER_CANVAS_SUBTITLE_BASELINE
assert(!baseline || baseline === '1.1.0', 'Only the frozen published 1.1.0 subtitle baseline is supported')
assert(!baseline || !process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'Historical subtitles cannot claim installed candidate provenance')
const candidate = baseline ? (await canvasHistorical()).find(item => item.name === 'published-1.1.0') : await canvasCandidate()
assert(candidate)
for (const core of ['published', 'candidate']) {
test(`${core}: canvas proxy loads and seeks native subtitle cues`, async ({ page }, testInfo) => {
await page.route('**/canvas-captions.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nOpening caption\n\n00:00:02.000 --> 00:00:07.000\nLater caption\n' }))
await page.route('**/canvas-replacement.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:00:00.000 --> 00:00:07.000\nReplacement caption\n' }))
await page.goto(`/test/player.html?core=${core}`)
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${candidate.source}; window.canvasFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, proxy: window.canvasFactory(), subtitle: { url: '/canvas-captions.vtt' } })
document.querySelector('#play').onclick = () => window.art.play()
document.querySelector('#pause').onclick = () => window.art.pause()
})
let completed = false
try {
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.click('#pause')
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(2)
await expect(page.locator('.art-subtitle')).toHaveText('Opening caption')
await page.evaluate(() => window.art.currentTime = 3)
await expect(page.locator('.art-subtitle')).toHaveText('Later caption')
await page.evaluate(async () => {
window.previousCanvasTrack = window.art.template.$track
await window.art.subtitle.switch('/canvas-replacement.vtt')
})
await expect.poll(() => page.evaluate(() => window.art.subtitle.cues.length)).toBe(1)
await page.evaluate(() => window.art.currentTime = 4)
await expect(page.locator('.art-subtitle')).toHaveText('Replacement caption')
expect(await page.evaluate(() => ({ previousConnected: window.previousCanvasTrack.isConnected, count: window.art.video.textTracks.length, parent: window.art.template.$track.parentNode === window.art.template.$player.querySelector('video') }))).toEqual({ previousConnected: false, count: 1, parent: true })
expect(await page.evaluate(() => {
const canvas = window.art.video
const marker = document.createElement('span')
const append = canvas.appendChild
const returned = append(marker)
const same = returned === marker && marker.parentNode === canvas
canvas.removeChild(marker)
window.canvasAppend = append
window.backingCanvasVideo = window.art.template.$player.querySelector('video')
return same && !marker.parentNode && typeof canvas.toDataURL() === 'string'
})).toBe(true)
completed = true
}
finally {
await testInfo.attach('canvas-subtitle-state', { contentType: 'application/json', body: JSON.stringify({ core, implementation: candidate.name, sha256: hash(candidate.source), ...await page.evaluate(() => ({ renderer: window.art.video.nodeName, trackParent: window.art.template.$track.parentNode?.nodeName, trackState: window.art.template.$track.readyState, cues: window.art.subtitle.cues.length, backingTracks: window.art.template.$player.querySelector('video')?.textTracks.length, text: window.art.template.$subtitle.textContent, time: window.art.currentTime })) }) })
await page.evaluate(() => window.art.destroy())
if (completed) {
expect(await page.evaluate(() => {
const track = document.createElement('track')
window.canvasAppend?.(track)
const video = window.backingCanvasVideo
return video ? { connected: video.isConnected, paused: video.paused, source: video.getAttribute('src'), restarted: track.parentNode === video } : null
})).toEqual({ connected: false, paused: true, source: null, restarted: false })
}
}
})
}
+16
View File
@@ -5,6 +5,22 @@ import { canvasCandidate, canvasEnvironment, canvasHistorical } from './helpers/
const implementation = await canvasCandidate()
const listenerCount = env => [...env.listeners.values()].reduce((count, set) => count + set.size, 0)
test('Canvas rolls back private media when initial subtitle track insertion fails', async () => {
const env = await canvasEnvironment(implementation)
const failure = new Error('Initial track insertion failed')
env.video.appendChild = () => {
throw failure
}
assert.throws(() => env.factory()(env.art), error => error === failure)
assert.equal(listenerCount(env), 0)
assert.equal(env.timers.size, 0)
assert.equal(env.frames.size, 0)
assert.equal(env.video.parentNode, undefined)
assert.equal(env.video.src, undefined)
assert.equal(env.video.paused, true)
assert.deepEqual([env.canvas.width, env.canvas.height], [0, 0])
})
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => {
+23 -1
View File
@@ -75,6 +75,24 @@ export async function canvasEnvironment(implementation, settings = {}) {
paused: true,
muted: false,
style: {},
children: [],
textTracks: [],
appendChild(node) {
node.parentNode?.removeChild(node)
this.children.push(node)
node.parentNode = this
if (node.nodeName === 'TRACK')
this.textTracks.push(node.track)
return node
},
removeChild(node) {
this.children.splice(this.children.indexOf(node), 1)
const index = this.textTracks.indexOf(node.track)
if (index >= 0)
this.textTracks.splice(index, 1)
node.parentNode = null
return node
},
play(...args) {
calls.push({ name: 'play', receiver: this, args })
this.paused = false
@@ -111,7 +129,11 @@ export async function canvasEnvironment(implementation, settings = {}) {
},
}
const art = {
constructor: { config: { events: ['loadedmetadata', 'play', 'pause', 'seeked', 'error'] }, utils: { createElement: tag => tag === 'canvas' ? canvas : video } },
constructor: { config: { events: ['loadedmetadata', 'play', 'pause', 'seeked', 'error'] }, utils: { createElement: (tag) => {
if (tag === 'track')
return { nodeName: 'TRACK', nodeType: 1, namespaceURI: 'http://www.w3.org/1999/xhtml', track: { mode: 'disabled', cues: [], activeCues: [] }, remove() { this.parentNode?.removeChild(this) } }
return tag === 'canvas' ? canvas : video
} } },
option: { autoSize: false },
template: { $player: player },
isDestroy: false,