mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-18 19:01:20 -08:00
fix(multiple-subtitles): repair old ASS conversion line breaks [PKG-MULTI-SUB-11]
This commit is contained in:
1 parent
8ae2762a23
commit
318de37e8a
22 files changed
+3268
-27
No files matched your search
@@ -1,5 +1,12 @@
|
||||
# Tests and fixture ownership
|
||||
|
||||
`test/multiple-subtitles-ass.test.js` verifies hashes of actual core5.1.2 main,
|
||||
legacy and source before comparing conversion behavior. It covers the collapsed
|
||||
main output, source-equivalent timings/text, valid/custom/empty passthrough and
|
||||
converter failure cleanup through the real candidate plugin. It runs in both
|
||||
`test:unit` and `test:multiple-subtitles`. This is native VTT conversion, not ASS
|
||||
layout/rendering acceptance for JASSUB.
|
||||
|
||||
`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
|
||||
|
||||
@@ -270,10 +270,16 @@ 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
|
||||
repeated updates, selection/reset and empty intervals. It now uses ASS for the third
|
||||
track on both hosts: task11 repairs the exact5.1.2 main converter defect inside
|
||||
the plugin. Task10's historical VTT substitution was not ASS acceptance. 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.
|
||||
|
||||
`multiple-subtitles-ass.spec.js` checks UTF16 decoding, explicit ASS type selection,
|
||||
multiline text/HTML, overlapping translation, selection/reset and empty intervals
|
||||
on5.1.2/5.1.7/5.3.0/5.4.0/candidate. The attachment records the unchanged original
|
||||
host converter output alongside actual native cues and browser/artifact hashes.
|
||||
It covers desktop video/track playback, not full ASS layout or physical devices.
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { multipleSubtitlesCandidate } from '../helpers/multiple-subtitles.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const candidate = await multipleSubtitlesCandidate()
|
||||
const ass = '[Events]\r\nDialogue: 0,0:00:01.25,0:00:03.75,Default,,0,0,0,,漢字 café, comma <b>bold</b>\\NSecond line\r\nDialogue: 0,0:00:04.25,0:00:06.75,Default,,0,0,0,,Later cue'
|
||||
|
||||
for (const core of ['published-5.1.2', 'published-5.1.7', 'published-5.3.0', 'published', 'candidate']) {
|
||||
test(`Multiple subtitles ASS / ${core}: encoded multiline cues, overlapping translation and selection`, async ({ page, browser }, testInfo) => {
|
||||
await page.route('**/test/ass-data.bin', route => route.fulfill({ contentType: 'application/octet-stream', body: Buffer.from(ass, 'utf16le') }))
|
||||
await page.route('**/test/ass-translation.vtt', route => route.fulfill({ contentType: 'text/vtt', body: 'WEBVTT\n\n00:02.000 --> 00:04.000\nTranslation\n' }))
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
await page.addScriptTag({ content: candidate.code })
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.evaluate(async () => {
|
||||
await window.art.plugins.add(window.artplayerPluginMultipleSubtitles({ subtitles: [
|
||||
{ name: 'ass', url: '/test/ass-data.bin', type: 'ass', encoding: 'utf-16le' },
|
||||
{ name: 'translation', url: '/test/ass-translation.vtt' },
|
||||
] }))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$track.track.cues?.length)).toBe(3)
|
||||
const native = await page.evaluate(() => Array.from(window.art.template.$track.track.cues, cue => ({ start: cue.startTime, end: cue.endTime, text: cue.getCueAsHTML().textContent })))
|
||||
expect(native.map(cue => [cue.start, cue.end])).toEqual([[1.25, 3.75], [2, 4], [4.25, 6.75]])
|
||||
expect(native[0].text).toContain('漢字 café, comma bold')
|
||||
expect(native[0].text).toContain('Second line')
|
||||
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-ass b')).toHaveText('bold')
|
||||
await expect(page.locator('.art-subtitle')).toContainText('Second line')
|
||||
await expect(page.locator('.art-subtitle-translation')).toHaveCount(0)
|
||||
await seek(2.5)
|
||||
await expect(page.locator('.art-subtitle-ass b')).toBeVisible()
|
||||
await expect(page.locator('.art-subtitle-translation')).toHaveText('Translation')
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.tracks(['translation']))
|
||||
await expect(page.locator('.art-subtitle-ass')).toHaveCount(0)
|
||||
await expect(page.locator('.art-subtitle-translation')).toHaveText('Translation')
|
||||
await page.evaluate(() => window.art.plugins.multipleSubtitles.reset())
|
||||
await expect(page.locator('.art-subtitle-ass b')).toBeVisible()
|
||||
await seek(5)
|
||||
await expect(page.locator('.art-subtitle-ass')).toHaveText('Later cue')
|
||||
await expect(page.locator('.art-subtitle-translation')).toHaveCount(0)
|
||||
await seek(7.5)
|
||||
await expect(page.locator('.art-subtitle-ass, .art-subtitle-translation')).toHaveCount(0)
|
||||
const rawHostOutput = await page.evaluate(ass => window.Artplayer.utils.assToVtt(ass), ass)
|
||||
await testInfo.attach('ass-conversion', { contentType: 'application/json', body: JSON.stringify({ core, browser: browser.version(), pluginSha256: hash(candidate.code), sourceSha256: hash(Buffer.from(ass, 'utf16le')), rawHostOutput, native, scope: 'Desktop native playback with UTF16 ASS, explicit type override, independently timed multiline/semantic captions, overlap, selection/reset and clearing; original core converter is unchanged.' }) })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
@@ -9,7 +9,6 @@ for (const core of ['published-5.1.2', 'published-5.1.7']) {
|
||||
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); }' })
|
||||
@@ -20,7 +19,7 @@ for (const core of ['published-5.1.2', 'published-5.1.7']) {
|
||||
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'
|
||||
const thirdFormat = '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)
|
||||
@@ -70,7 +69,7 @@ for (const core of ['published-5.1.2', 'published-5.1.7']) {
|
||||
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 testInfo.attach('legacy-captions', { contentType: 'application/json', body: JSON.stringify({ core, browser: browser.version(), pluginSha256: hash(candidate.code), thirdFormat, state, scope: 'Native VTT/SRT/ASS overlaps on both old cores, semantic HTML/CSS, legacy scalar event payload, original host method and cue identities, repeated update/selection/reset/empty intervals' }) })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import fs from 'node:fs'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Frozen converter regressions use the repository Node runner.
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { parseHTML } from 'linkedom'
|
||||
import { ensureArchive, hash, readMember } from '../refactor/scripts/releases.mjs'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
import { multipleSubtitlesCandidate, multipleSubtitlesEnvironment } from './helpers/multiple-subtitles.js'
|
||||
|
||||
const release = JSON.parse(fs.readFileSync('refactor/baselines/multiple-subtitles-cores.json')).releases.find(item => item.version === '5.1.2')
|
||||
const archive = await ensureArchive(release)
|
||||
function read(member) {
|
||||
const bytes = readMember(archive, member)
|
||||
assert.equal(hash(bytes), release.files[member])
|
||||
return bytes.toString()
|
||||
}
|
||||
const published = ['main', 'legacy'].map((name) => {
|
||||
const module = { exports: {} }
|
||||
vm.runInNewContext(read(`package/dist/artplayer${name === 'main' ? '' : '.legacy'}.js`), { module, exports: module.exports }, { timeout: 5000 })
|
||||
return { name, utils: (module.exports.default || module.exports).utils }
|
||||
})
|
||||
const oldSource = await import(`data:text/javascript;base64,${Buffer.from(read('package/src/utils/subtitle.js')).toString('base64')}`)
|
||||
const candidate = await multipleSubtitlesCandidate()
|
||||
const { parseTracks, assToVtt, convertAss } = await loadModules({
|
||||
parseTracks: { file: 'packages/artplayer-plugin-multiple-subtitles/src/merge', name: 'parseTracks' },
|
||||
assToVtt: { file: 'packages/artplayer/src/utils/subtitle', name: 'assToVtt' },
|
||||
convertAss: 'packages/artplayer-plugin-multiple-subtitles/src/ass-conversion',
|
||||
})
|
||||
const dialogue = (text, start = '0:00:01.25', end = '0:00:03.75') => `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`
|
||||
const html = text => parseHTML(`<html><body>${text}</body></html>`).document.body.innerHTML
|
||||
const samples = [
|
||||
dialogue('Plain'),
|
||||
dialogue('漢字 café & <b>bold</b>'),
|
||||
dialogue('{\\i1}Italic{\\i0}\\N Second line '),
|
||||
`${dialogue('First, comma')}\r\n${dialogue('Second', '0:00:02.50', '0:00:04.25')}`,
|
||||
dialogue('Hour', '123:04:05.67', '124:05:06.78'),
|
||||
`[Events]\nComment: not a caption\n${dialogue('Literal 2 00:00:02.000 --> 00:00:03.000 payload')}\n`,
|
||||
]
|
||||
|
||||
test('Multiple subtitles ASS adapter preserves custom, valid, empty and nonmatching converter output exactly', () => {
|
||||
for (const converted of [
|
||||
'',
|
||||
'WEBVTT ',
|
||||
'WEBVTT\n\n',
|
||||
'Custom result',
|
||||
oldSource.assToVtt(samples[0]),
|
||||
'WEBVTT 1 00:00:01.250 --> 00:00:03.750 Changed by user',
|
||||
'WEBVTT 1 00:00:01.250 --> 00:00:03.750 Plain\n\n',
|
||||
]) {
|
||||
let calls = 0
|
||||
const actual = convertAss(samples[0], (input) => {
|
||||
calls++
|
||||
assert.equal(input, samples[0])
|
||||
return converted
|
||||
})
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(actual, converted)
|
||||
}
|
||||
for (const text of ['', '[Events]\nComment: no dialogue', dialogue('Invalid decimal', '0:00:01x25')]) {
|
||||
const converted = published[0].utils.assToVtt(text)
|
||||
assert.equal(convertAss(text, published[0].utils.assToVtt), converted)
|
||||
}
|
||||
})
|
||||
|
||||
test('Multiple subtitles ASS adapter propagates converter failure without mounting resources', async () => {
|
||||
const error = new Error('Custom conversion failed')
|
||||
let calls = 0
|
||||
const env = multipleSubtitlesEnvironment(candidate, {
|
||||
responses: { 'a.ass': samples[0] },
|
||||
coreUtils: { assToVtt() {
|
||||
calls++
|
||||
throw error
|
||||
} },
|
||||
})
|
||||
await assert.rejects(env.factory({ subtitles: [{ name: 'ass', url: 'a.ass' }] })(env.art), value => value === error)
|
||||
assert.equal(calls, 1)
|
||||
assert.equal(env.initialized.length, 0)
|
||||
assert.equal(env.liveBlobs.size, 0)
|
||||
assert.equal(env.listeners.get('destroy').size, 0)
|
||||
})
|
||||
|
||||
test('Multiple subtitles frozen5.1.2 main loses VTT line breaks while legacy and source retain them', () => {
|
||||
assert.equal(published[0].utils.assToVtt(dialogue('Plain')), 'WEBVTT 1 00:00:01.250 --> 00:00:03.750 Plain')
|
||||
for (const sample of samples) {
|
||||
assert.equal(published[1].utils.assToVtt(sample), oldSource.assToVtt(sample))
|
||||
assert.equal(assToVtt(sample), oldSource.assToVtt(sample))
|
||||
}
|
||||
})
|
||||
|
||||
for (const { name, utils } of published) {
|
||||
for (const [index, sample] of samples.entries()) {
|
||||
test(`Multiple subtitles ASS5.1.2 ${name} sample${index}: preserve source cue times and text`, async () => {
|
||||
const env = multipleSubtitlesEnvironment(candidate, { coreUtils: utils, responses: { 'a.ass': sample } })
|
||||
const result = await env.factory({ subtitles: [{ name: 'ass', url: 'a.ass' }] })(env.art)
|
||||
const expected = parseTracks([oldSource.assToVtt(sample)], [{ name: 'ass' }])[0].cues.map(cue => [cue.startTime, cue.endTime, html(cue.text)])
|
||||
const actual = () => env.latestText().then(vtt => parseTracks([env.utils.unescape(vtt)], [{ name: 'ass' }])[0].cues.map(cue => [cue.startTime, cue.endTime, html(cue.text.replace(/<div class="art-subtitle-ass">|<\/div>/g, ''))]))
|
||||
assert.deepEqual(await actual(), expected)
|
||||
result.tracks(['ass'])
|
||||
assert.deepEqual(await actual(), expected)
|
||||
result.reset()
|
||||
assert.deepEqual(await actual(), expected)
|
||||
assert.equal(env.requests.length, 1)
|
||||
env.emit('destroy')
|
||||
assert.equal(env.liveBlobs.size, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user