fix(ui): fold the source offset into exported lyrics

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-10-17 12:39:09 +03:00
parent 58fdfb4e65
commit 1d01a7f787
No known key found for this signature in database
2 changed files with 49 additions and 11 deletions

View File

@ -85,25 +85,32 @@ const timedCues = (groups) =>
),
)
const serializeLrc = (lyric, lines) => {
const serializeLrc = (lyric, lines, baseOffset) => {
const cuesByIndex = cuesByLineIndex(lyric)
const withBase = (value) =>
value == null ? value : Number(value) + baseOffset
return lines
.map((line, index) => {
const cues = timedCues(cuesByIndex.get(index) || [])
if (cues.length === 0) {
return `${lrcStamp(line?.start)}${line?.value ?? ''}`
return `${lrcStamp(withBase(line?.start))}${line?.value ?? ''}`
}
const body = cues
.map((cue) => `${lrcStamp(cue.start, '<', '>')}${cue.value ?? ''}`)
.map(
(cue) =>
`${lrcStamp(withBase(cue.start), '<', '>')}${cue.value ?? ''}`,
)
.join('')
return `${lrcStamp(line?.start)}${body}`
return `${lrcStamp(withBase(line?.start))}${body}`
})
.join('\n')
}
const serializeTtml = (lyric, lines) => {
const serializeTtml = (lyric, lines, baseOffset) => {
const lang = typeof lyric.lang === 'string' ? lyric.lang.trim() : ''
const cuesByIndex = cuesByLineIndex(lyric)
const withBase = (value) =>
value == null ? value : Number(value) + baseOffset
const hasCues = [...cuesByIndex.values()].some(
(groups) => timedCues(groups).length > 0,
)
@ -114,16 +121,20 @@ const serializeTtml = (lyric, lines) => {
...(lang && lang !== 'xxx' ? [`xml:lang="${escapeXml(lang)}"`] : []),
]
const paragraphs = lines.map((line, index) => {
const begin = line?.start == null ? '' : ` begin="${ttmlClock(line.start)}"`
const end = line?.end == null ? '' : ` end="${ttmlClock(line.end)}"`
const begin =
line?.start == null ? '' : ` begin="${ttmlClock(withBase(line.start))}"`
const end =
line?.end == null ? '' : ` end="${ttmlClock(withBase(line.end))}"`
const cues = timedCues(cuesByIndex.get(index) || [])
const content =
cues.length > 0
? cues
.map(
(cue) =>
`<span begin="${ttmlClock(cue.start)}"${
cue.end == null ? '' : ` end="${ttmlClock(cue.end)}"`
`<span begin="${ttmlClock(withBase(cue.start))}"${
cue.end == null
? ''
: ` end="${ttmlClock(withBase(cue.end))}"`
}>${escapeXml(cue.value)}</span>`,
)
.join('')
@ -143,11 +154,15 @@ const serializeTtml = (lyric, lines) => {
export const serializeLyrics = (lyric, format) => {
if (!lyric) return ''
const lines = Array.isArray(lyric.line) ? lyric.line : []
// The source's own offset is a separate field on the response; fold it in so
// the exported timing matches what the panel plays (the listener's local
// nudge intentionally stays out).
const baseOffset = Number(lyric.offset) || 0
switch (lyricsFileExtension(format)) {
case 'ttml':
return serializeTtml(lyric, lines)
return serializeTtml(lyric, lines, baseOffset)
case 'lrc':
return serializeLrc(lyric, lines)
return serializeLrc(lyric, lines, baseOffset)
default:
return lines.map((line) => line?.value ?? '').join('\n')
}

View File

@ -56,6 +56,29 @@ describe('lyrics export', () => {
)
})
it('folds the source offset into the exported timestamps', () => {
const lyric = {
kind: 'main',
lang: 'eng',
synced: true,
offset: -500,
line: [line('Hello world', 8000, 11000)],
cueLine: [
cueLine(0, 'Hello world', [
{ start: 8000, value: 'Hello ' },
{ start: 9500, value: 'world' },
]),
],
}
expect(serializeLyrics(lyric, 'elrc')).toBe(
'[00:07.50]<00:07.50>Hello <00:09.00>world',
)
expect(serializeLyrics(lyric, 'ttml')).toContain(
'<p begin="00:00:07.500" end="00:00:10.500">',
)
})
it('serializes line timing to a plain LRC stamp per line', () => {
const lyric = {
kind: 'main',