mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-25 16:11:20 -08:00
fix(multiple-subtitles): render overlapping cues on legacy hosts [PKG-MULTI-SUB-10]
This commit is contained in:
1 parent
31f9024bff
commit
8ae2762a23
24 files changed
+3465
-56
No files matched your search
@@ -1,5 +1,11 @@
|
||||
# Tests and fixture ownership
|
||||
|
||||
`test/multiple-subtitles-caption.test.js` checks the old-host view adapter with the
|
||||
actual plugin bundle: native order, original update identity/scalar event payload,
|
||||
escape changes, timestamp cleanup, modern-host isolation and destroy/registration
|
||||
failure cleanup. It runs in `test:unit` and `test:multiple-subtitles`, without new
|
||||
dependencies. Native cue timing is separately verified in the browser suite.
|
||||
|
||||
`test/multiple-subtitles-entities.test.js` checks named entities, literal tags,
|
||||
double encoding, nested semantic tags and selection/reset through the real plugin.
|
||||
The vendor parity suite stays unchanged: the plugin uses the parser's existing
|
||||
|
||||
@@ -266,3 +266,14 @@ syntax failures remain in the historical parser tests. Mobile cases use an Andro
|
||||
UA and synthetic DOM touches through the real core handlers, not trusted hardware
|
||||
input. Fullscreen mouse placement first waits for the progress control to be stable;
|
||||
the earlier hidden-preview screenshot timeout remains in the checkpoint evidence.
|
||||
|
||||
`multiple-subtitles-legacy.spec.js` uses real 5.1.2/5.1.7 cores and the candidate
|
||||
plugin to check three independently timed captions, VTT/SRT, semantic HTML/CSS,
|
||||
the original update method and native cue identities, scalar event payloads,
|
||||
repeated updates, selection/reset and empty intervals. It uses ASS for the third
|
||||
track on5.1.7; the5.1.2 published converter's malformed VTT remains a separate
|
||||
task11 failure and is not covered by substituting a VTT fixture. Use
|
||||
`ARTPLAYER_MULTIPLE_SUBTITLES_ARTIFACT` for actual main/legacy package bytes.
|
||||
The combination suite retains explicit published1.1/1.2 overlap-loss observations
|
||||
while requiring candidate captions to contain both languages. A passing historical
|
||||
observation is not a compatibility pass.
|
||||
@@ -172,18 +172,19 @@ for (const core of ['published-5.1.2', 'published-5.1.7']) {
|
||||
await window.art.plugins.add(window.multipleFactory({ subtitles: [{ name: 'a', url: '/test/combination.vtt' }, { name: 'b', url: '/test/combination.srt' }] }))
|
||||
})
|
||||
const indexMerge = implementation.version === '1.0.0'
|
||||
const displaysBoth = indexMerge || implementation.name === 'candidate'
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$track.track.cues?.length)).toBe(indexMerge ? 1 : 2)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.locator('#pause').click()
|
||||
await seek(page, 1.5)
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveText('First')
|
||||
// This is a deficiency probe, not successful simultaneous-caption acceptance.
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveCount(indexMerge ? 1 : 0)
|
||||
// Historical 1.1/1.2 failures remain observations; candidate must display both.
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveCount(displaysBoth ? 1 : 0)
|
||||
const state = await page.evaluate(() => ({ native: Array.from(window.art.template.$track.track.activeCues, cue => cue.getCueAsHTML().textContent), displayed: window.art.template.$subtitle.textContent }))
|
||||
expect(state.native).toEqual(indexMerge ? ['FirstTranslation'] : ['First', 'Translation'])
|
||||
expect(state.displayed).toBe(indexMerge ? 'FirstTranslation' : 'First')
|
||||
await testInfo.attach('single-active-cue-boundary', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, state, simultaneousCaptionsAccepted: indexMerge, unresolvedCandidateGap: !indexMerge, explanation: '1.0.0 combines by array index; 1.1.0 onward retains separately timed cues. Old core displays only its first active cue. Candidate gap remains open, even when this observation test passes.' }) })
|
||||
expect(state.displayed).toBe(displaysBoth ? 'FirstTranslation' : 'First')
|
||||
await testInfo.attach('single-active-cue-boundary', { contentType: 'application/json', body: JSON.stringify({ core, implementation: implementation.name, state, simultaneousCaptionsAccepted: displaysBoth, historicalCaptionLoss: !displaysBoth, explanation: '1.0.0 combines by array index; 1.1/1.2 retain separate cues but the old core displays only its first. Candidate adapts the old view while retaining separate native timings.' }) })
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks(['b']))
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveText('Translation')
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveCount(0)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { multipleSubtitlesCandidate } from '../helpers/multiple-subtitles.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const candidate = await multipleSubtitlesCandidate()
|
||||
|
||||
for (const core of ['published-5.1.2', 'published-5.1.7']) {
|
||||
test(`Multiple subtitles legacy host ${core}: independently timed captions overlap and clear`, async ({ page, browser }, testInfo) => {
|
||||
await page.route('**/test/legacy-a.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:01.000 --> 00:03.000\n<b>A & safe</b>\n' }))
|
||||
await page.route('**/test/legacy-b.srt', route => route.fulfill({ contentType: 'text/plain', body: '1\n00:00:02,000 --> 00:00:04,000\nB\n' }))
|
||||
await page.route('**/test/legacy-c.ass', route => route.fulfill({ contentType: 'text/plain', body: 'Dialogue: 0,0:00:02.25,0:00:02.75,Default,,0,0,0,,C' }))
|
||||
await page.route('**/test/legacy-c.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:02.250 --> 00:02.750\nC\n' }))
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: candidate.code })
|
||||
await page.addStyleTag({ content: '.art-subtitle-a { color: rgb(255, 0, 0); } .art-subtitle-b { color: rgb(0, 0, 255); }' })
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
window.originalSubtitleUpdate = window.art.subtitle.update
|
||||
window.legacyPayloads = []
|
||||
window.art.on('subtitleUpdate', text => window.legacyPayloads.push(text))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
const thirdFormat = core === 'published-5.1.2' ? 'vtt' : 'ass'
|
||||
await page.evaluate(async (thirdFormat) => {
|
||||
await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [{ name: 'a', url: '/test/legacy-a.vtt' }, { name: 'b', url: '/test/legacy-b.srt' }, { name: 'c', url: `/test/legacy-c.${thirdFormat}` }] }))
|
||||
}, thirdFormat)
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$track.track.cues?.length)).toBe(3)
|
||||
await page.evaluate(() => {
|
||||
window.cueIdentities = Array.from(window.art.template.$track.track.cues)
|
||||
})
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
|
||||
await page.locator('#pause').click()
|
||||
async function seek(time) {
|
||||
await page.evaluate(time => new Promise((resolve) => {
|
||||
window.art.once('video:seeked', resolve)
|
||||
window.art.seek = time
|
||||
}), time)
|
||||
}
|
||||
await seek(1.5)
|
||||
await expect(page.locator('.art-subtitle-a b')).toHaveText('A & safe')
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveCount(0)
|
||||
await seek(2.5)
|
||||
await expect(page.locator('.art-subtitle-a b')).toHaveText('A & safe')
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveText('B')
|
||||
await expect(page.locator('.art-subtitle-c')).toHaveText('C')
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveCSS('color', 'rgb(255, 0, 0)')
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveCSS('color', 'rgb(0, 0, 255)')
|
||||
const state = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.subtitle.update()
|
||||
art.subtitle.update()
|
||||
const cues = Array.from(art.template.$track.track.cues)
|
||||
return { sameUpdate: art.subtitle.update === window.originalSubtitleUpdate, sameCues: cues.every((cue, index) => cue === window.cueIdentities[index]), times: cues.map(cue => [cue.startTime, cue.endTime]), legacyPayload: window.legacyPayloads.at(-1), firstText: art.template.$track.track.activeCues[0].text, counts: ['a', 'b', 'c'].map(name => art.template.$subtitle.querySelectorAll(`.art-subtitle-${name}`).length) }
|
||||
})
|
||||
expect(state.sameUpdate).toBe(true)
|
||||
expect(state.sameCues).toBe(true)
|
||||
expect(state.times).toEqual([[1, 3], [2, 4], [2.25, 2.75]])
|
||||
expect(state.legacyPayload).toBe(state.firstText)
|
||||
expect(state.counts).toEqual([1, 1, 1])
|
||||
await seek(3.5)
|
||||
await expect(page.locator('.art-subtitle-a')).toHaveCount(0)
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveText('B')
|
||||
await expect(page.locator('.art-subtitle-c')).toHaveCount(0)
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks([]))
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveCount(0)
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
|
||||
await expect(page.locator('.art-subtitle-b')).toHaveText('B')
|
||||
await seek(2.5)
|
||||
await expect(page.locator('.art-subtitle-c')).toHaveText('C')
|
||||
await seek(4.5)
|
||||
await expect(page.locator('.art-subtitle-a, .art-subtitle-b, .art-subtitle-c')).toHaveCount(0)
|
||||
await testInfo.attach('legacy-captions', { contentType: 'application/json', body: JSON.stringify({ core, browser: browser.version(), pluginSha256: hash(candidate.code), thirdFormat, state, scope: 'Native VTT/SRT overlaps (ASS additionally on 5.1.7), semantic HTML/CSS, legacy scalar event payload, original host method and cue identities, repeated update/selection/reset/empty intervals', limitation: '5.1.2 published ASS converter loses required VTT line breaks; tracked separately in PKG-MULTI-SUB-11.' }) })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Caption adapters use the repository Node runner.
|
||||
import test from 'node:test'
|
||||
import { parseHTML } from 'linkedom'
|
||||
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment } from './helpers/multiple-subtitles.js'
|
||||
|
||||
const candidate = await multipleSubtitlesCandidate()
|
||||
|
||||
function host({ modern = false } = {}) {
|
||||
const env = multipleSubtitlesEnvironment(candidate)
|
||||
const { document } = parseHTML('<html><body><div id="subtitle"></div></body></html>')
|
||||
const target = document.getElementById('subtitle')
|
||||
let active = []
|
||||
env.art.template = { $subtitle: target }
|
||||
Object.defineProperty(env.art.subtitle, 'activeCue', {
|
||||
get() { throw new Error('Capability checks must not read a cue') },
|
||||
})
|
||||
Object.defineProperty(env.art.subtitle, 'textTrack', { get: () => ({ activeCues: active }) })
|
||||
if (modern)
|
||||
Object.defineProperty(env.art.subtitle, 'activeCues', { get() { throw new Error('Modern rendering belongs to the core') } })
|
||||
env.art.subtitle.update = () => {
|
||||
target.innerHTML = active[0]?.text || ''
|
||||
if (active.length)
|
||||
env.emit('subtitleUpdate', active[0].text)
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
target,
|
||||
setActive(cues) { active = cues },
|
||||
}
|
||||
}
|
||||
|
||||
test('Multiple subtitles legacy caption view preserves cue order, event payload and original update', async () => {
|
||||
const env = host()
|
||||
const update = env.art.subtitle.update
|
||||
const payloads = []
|
||||
const views = []
|
||||
env.art.on('subtitleUpdate', (value) => {
|
||||
payloads.push(value)
|
||||
views.push(['before', env.target.textContent])
|
||||
})
|
||||
await env.factory({ subtitles: [] })(env.art)
|
||||
env.art.on('subtitleUpdate', () => views.push(['after', env.target.textContent]))
|
||||
const cues = Object.freeze([
|
||||
Object.freeze({ text: '<div class="a"><b>A & safe</b></div>', startTime: 1, endTime: 3 }),
|
||||
Object.freeze({ text: '<div class="b">B</div>', startTime: 2, endTime: 4 }),
|
||||
Object.freeze({ text: '<div class="c">C</div>', startTime: 2.25, endTime: 2.75 }),
|
||||
])
|
||||
env.setActive(cues)
|
||||
for (let index = 0; index < 3; index++) {
|
||||
update()
|
||||
assert.equal(env.target.textContent, 'A & safeBC')
|
||||
assert.deepEqual(Array.from(env.target.children, node => node.className), ['a', 'b', 'c'])
|
||||
}
|
||||
assert.equal(env.art.subtitle.update, update)
|
||||
assert.deepEqual(payloads, [cues[0].text, cues[0].text, cues[0].text])
|
||||
assert.deepEqual(views, Array.from({ length: 3 }, () => [['before', 'A & safe'], ['after', 'A & safeBC']]).flat())
|
||||
assert.equal(env.listeners.get('subtitleAfterUpdate'), undefined)
|
||||
env.setActive([cues[1]])
|
||||
update()
|
||||
assert.equal(env.target.innerHTML, cues[1].text)
|
||||
env.setActive([])
|
||||
update()
|
||||
assert.equal(env.target.innerHTML, '')
|
||||
assert.equal(payloads.length, 4)
|
||||
env.emit('destroy')
|
||||
})
|
||||
|
||||
test('Multiple subtitles legacy view respects later escape changes and blank CRLF lines', async () => {
|
||||
const env = host()
|
||||
await env.factory({ subtitles: [] })(env.art)
|
||||
env.art.option.subtitle.escape = true
|
||||
env.setActive([{ text: '<b>A & B</b>\r\n\r\nTail' }, { text: '<img src="bad" onerror="bad()">' }])
|
||||
env.art.subtitle.update()
|
||||
assert.equal(env.target.querySelectorAll('b, img').length, 0)
|
||||
assert.deepEqual(Array.from(env.target.children, node => node.textContent), ['<b>A & B</b>', '', 'Tail', '<img src="bad" onerror="bad()">'])
|
||||
assert(Array.from(env.target.children).every(node => node.className === 'art-subtitle-line'))
|
||||
env.emit('destroy')
|
||||
})
|
||||
|
||||
test('Multiple subtitles legacy view cleans timestamp markers for both single and overlapping cues', async () => {
|
||||
const env = host()
|
||||
await env.factory({ subtitles: [] })(env.art)
|
||||
const cue = { text: '<div class="a">A<c.artplayer-multiple-subtitles-timestamp><00:02.000></c><b>B</b></div>' }
|
||||
for (const cues of [[cue], [cue, { text: '<div>C</div>' }]]) {
|
||||
env.setActive(cues)
|
||||
env.art.subtitle.update()
|
||||
assert.equal(env.target.querySelector('b').textContent, 'B')
|
||||
assert.equal(env.target.textContent, cues.length === 1 ? 'AB' : 'ABC')
|
||||
assert.equal(env.target.getElementsByTagName('c.artplayer-multiple-subtitles-timestamp').length, 0)
|
||||
}
|
||||
env.emit('destroy')
|
||||
})
|
||||
|
||||
test('Multiple subtitles modern hosts retain their own view and never install the legacy listener', async () => {
|
||||
const env = host({ modern: true })
|
||||
await env.factory({ subtitles: [] })(env.art)
|
||||
env.target.innerHTML = '<span>Core view</span>'
|
||||
env.setActive([{ text: 'A' }, { text: 'B' }])
|
||||
env.emit('subtitleUpdate', 'A')
|
||||
env.emit('subtitleAfterUpdate', ['A', 'B'])
|
||||
assert.equal(env.target.innerHTML, '<span>Core view</span>')
|
||||
assert.equal(env.listeners.get('subtitleUpdate'), undefined)
|
||||
assert.equal(env.listeners.get('subtitleAfterUpdate').size, 1)
|
||||
env.emit('destroy')
|
||||
assert.equal(env.listeners.get('subtitleAfterUpdate').size, 0)
|
||||
})
|
||||
|
||||
test('Multiple subtitles legacy listener is removed on destroy and captured late callbacks are inert', async () => {
|
||||
const env = host()
|
||||
await env.factory({ subtitles: [] })(env.art)
|
||||
const callback = [...env.listeners.get('subtitleUpdate')][0]
|
||||
env.setActive([{ text: 'A' }, { text: 'B' }])
|
||||
env.emit('destroy')
|
||||
env.target.innerHTML = '<span>After destroy</span>'
|
||||
callback('A')
|
||||
assert.equal(env.target.textContent, 'After destroy')
|
||||
assert.equal(env.listeners.get('subtitleUpdate').size, 0)
|
||||
assert.equal(env.listeners.get('destroy').size, 0)
|
||||
})
|
||||
|
||||
test('Multiple subtitles legacy listener registration failure unwinds both listeners', async () => {
|
||||
const env = host()
|
||||
const on = env.art.on
|
||||
const error = new Error('Listener registration failed')
|
||||
env.art.on = (name, callback) => {
|
||||
on(name, callback)
|
||||
if (name === 'subtitleUpdate')
|
||||
throw error
|
||||
return env.art
|
||||
}
|
||||
await assert.rejects(env.factory({ subtitles: [] })(env.art), value => value === error)
|
||||
assert.equal(env.listeners.get('subtitleUpdate').size, 0)
|
||||
assert.equal(env.listeners.get('destroy').size, 0)
|
||||
assert.equal(env.liveBlobs.size, 0)
|
||||
})
|
||||
Reference in new issue
Block a user