mirror of
https://github.com/navidrome/navidrome.git
synced 2026-07-09 17:18:45 +00:00
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (Windows) (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
* fix(lyrics): correct TTML background-vocal cue timing and whitespace Two parsing defects surfaced by Apple Music TTML files that mix a main vocal with an x-bg (background) span group within the same line: - Cue end-time normalization ran over the whole line's cue list in document order. Background cues are stored after the main cues but interleave earlier on the timeline, so the next-cue clamp collapsed the last main cue's end down to its own start (start == end). End times are now normalized per agent group, matching how the Subsonic serializer already groups cues, so parallel layers no longer corrupt each other. - Whitespace between elements was treated as significant: pretty-printed (indented) TTML injected spurious newlines into the line text, turning one line into many. Per TTML2 default xml:space handling (linefeeds treat-as-space, whitespace-collapse), formatting whitespace now collapses to a single space and hard line breaks come only from <br/>. The line-level value and per-agent cueLine.value remain the full line text, as required by the OpenSubsonic songLyrics v2 contract; the per-agent text is carried in each cueLine's cue[] array. Two existing tests that encoded the buggy newline-as-break behavior are corrected; new tests cover whitespace collapse, <br/> preservation, and interleaved background cue timing. * fix(lyrics): only collapse XML whitespace, preserve other Unicode spaces Whitespace collapsing used unicode.IsSpace, which matches more than the XML S production (space, tab, CR, LF): it also folds characters like NBSP and U+3000 into a regular space, silently altering content. Restrict collapsing to the four XML whitespace characters so other Unicode spaces pass through unchanged, and add a regression test. Also clarify the doc comment that collapsing is applied unconditionally (xml:space="preserve" is not supported).
1296 lines
30 KiB
Go
1296 lines
30 KiB
Go
package model
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/xml"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/utils/gg"
|
|
"github.com/navidrome/navidrome/utils/str"
|
|
)
|
|
|
|
const (
|
|
defaultTTMLFrameRate = 30.0
|
|
defaultTTMLSubFrameRate = 1.0
|
|
defaultTTMLTickRate = 1.0
|
|
|
|
ttmlBackgroundAgentPrefix = "__nd_bg__|"
|
|
)
|
|
|
|
var offsetTimeRegex = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(h|m|s|ms|f|t)$`)
|
|
var xmlEncodingRegex = regexp.MustCompile(`(?i)<\?xml([^>]*?)encoding\s*=\s*["'][^"']+["']([^>]*)\?>`)
|
|
|
|
type ttmlTimeKind int
|
|
|
|
const (
|
|
ttmlTimeAbsolute ttmlTimeKind = iota
|
|
ttmlTimeOffset
|
|
ttmlTimeAmbiguous
|
|
)
|
|
|
|
type ttmlTimingParams struct {
|
|
frameRate float64
|
|
subFrameRate float64
|
|
tickRate float64
|
|
}
|
|
|
|
type ttmlTimingContext struct {
|
|
lang string
|
|
role string
|
|
agentID string
|
|
begin int64
|
|
hasBegin bool
|
|
end int64
|
|
hasEnd bool
|
|
invalid bool
|
|
}
|
|
|
|
type ttmlLineRef struct {
|
|
order int
|
|
line Line
|
|
}
|
|
|
|
type ttmlMetadataEntry struct {
|
|
key string
|
|
line Line
|
|
seq int
|
|
}
|
|
|
|
type ttmlResolvedMetadataLine struct {
|
|
order int
|
|
seq int
|
|
line Line
|
|
}
|
|
|
|
type ttmlDefinedAgent struct {
|
|
ID string
|
|
Type string
|
|
Name string
|
|
}
|
|
|
|
type ttmlPiece struct {
|
|
raw string
|
|
cue *Cue
|
|
isBreak bool
|
|
}
|
|
|
|
type ttmlParser struct {
|
|
decoder *xml.Decoder
|
|
params ttmlTimingParams
|
|
|
|
mainLangOrder []string
|
|
mainLinesByLang map[string][]Line
|
|
|
|
mainLineRefsByKey map[string]ttmlLineRef
|
|
mainLineOrder int
|
|
|
|
translationLangOrder []string
|
|
translationEntriesByLg map[string][]ttmlMetadataEntry
|
|
|
|
pronunciationLangOrder []string
|
|
pronunciationEntriesByLg map[string][]ttmlMetadataEntry
|
|
|
|
definedAgents map[string]ttmlDefinedAgent
|
|
|
|
metadataSeq int
|
|
}
|
|
|
|
func isTTMLDocument(contents []byte) bool {
|
|
decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents)))
|
|
for {
|
|
token, err := decoder.Token()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if start, ok := token.(xml.StartElement); ok {
|
|
return strings.EqualFold(start.Name.Local, "tt")
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseTTML(defaultLang string, contents []byte) (LyricList, error) {
|
|
contents = xmlEncodingRegex.ReplaceAll(contents, []byte(`<?xml$1encoding="UTF-8"$2?>`))
|
|
|
|
// Skip non-TTML content so sniffing doesn't run the full TTML parse on plain
|
|
// text — isTTMLDocument does a cheap decode that stops at the first element.
|
|
// Checked after the encoding fixup so UTF-16-declared documents are recognized.
|
|
if !isTTMLDocument(contents) {
|
|
return nil, nil
|
|
}
|
|
|
|
p := ttmlParser{
|
|
decoder: xml.NewDecoder(bytes.NewReader(contents)),
|
|
params: ttmlTimingParams{
|
|
frameRate: defaultTTMLFrameRate,
|
|
subFrameRate: defaultTTMLSubFrameRate,
|
|
tickRate: defaultTTMLTickRate,
|
|
},
|
|
mainLinesByLang: make(map[string][]Line),
|
|
mainLineRefsByKey: make(map[string]ttmlLineRef),
|
|
translationEntriesByLg: make(map[string][]ttmlMetadataEntry),
|
|
pronunciationEntriesByLg: make(map[string][]ttmlMetadataEntry),
|
|
definedAgents: make(map[string]ttmlDefinedAgent),
|
|
}
|
|
|
|
root := ttmlTimingContext{lang: normalizeLyricLang(defaultLang)}
|
|
|
|
for {
|
|
token, err := p.decoder.Token()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
start, ok := token.(xml.StartElement)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
if err := p.parseElement(start, root); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return p.toLyricList(), nil
|
|
}
|
|
|
|
func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingContext) error {
|
|
local := strings.ToLower(start.Name.Local)
|
|
if local == "tt" {
|
|
p.updateTimingParams(start.Attr)
|
|
}
|
|
|
|
switch local {
|
|
case "translation":
|
|
return p.parseMetadataTrack(start, parent, LyricKindTranslation)
|
|
case "transliteration":
|
|
return p.parseMetadataTrack(start, parent, LyricKindPronunciation)
|
|
case "agent":
|
|
return p.parseAgentDefinition(start)
|
|
}
|
|
|
|
ctx := p.childContext(start.Attr, parent)
|
|
if local == "p" {
|
|
lineText, tokens, err := p.parseParagraph(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ctx.invalid || lineText == "" {
|
|
return nil
|
|
}
|
|
|
|
parsedLine := Line{Value: lineText}
|
|
if ctx.hasBegin {
|
|
startMs := ctx.begin
|
|
parsedLine.Start = &startMs
|
|
}
|
|
if ctx.hasEnd {
|
|
endMs := ctx.end
|
|
parsedLine.End = &endMs
|
|
}
|
|
if len(tokens) > 0 {
|
|
parsedLine.Cue = tokens
|
|
}
|
|
parsedLine = normalizeLineTiming(parsedLine)
|
|
|
|
lineKey, _ := attrValue(start.Attr, "key")
|
|
p.addMainLine(ctx.lang, lineKey, parsedLine)
|
|
return nil
|
|
}
|
|
|
|
for {
|
|
token, err := p.decoder.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch t := token.(type) {
|
|
case xml.StartElement:
|
|
nextParent := ctx
|
|
if ctx.invalid {
|
|
// Best effort: ignore invalid timing in container elements, and
|
|
// continue traversing descendants with parent context.
|
|
nextParent = parent
|
|
}
|
|
if err := p.parseElement(t, nextParent); err != nil {
|
|
return err
|
|
}
|
|
case xml.EndElement:
|
|
if strings.EqualFold(t.Name.Local, start.Name.Local) {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *ttmlParser) parseMetadataTrack(start xml.StartElement, parent ttmlTimingContext, kind string) error {
|
|
ctx := p.childContext(start.Attr, parent)
|
|
lang := normalizeLyricLang(ctx.lang)
|
|
|
|
for {
|
|
token, err := p.decoder.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch t := token.(type) {
|
|
case xml.StartElement:
|
|
if strings.EqualFold(t.Name.Local, "text") {
|
|
entry, ok, err := p.parseMetadataText(t, ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ok {
|
|
p.addMetadataEntry(kind, lang, entry)
|
|
}
|
|
continue
|
|
}
|
|
|
|
nextParent := ctx
|
|
if ctx.invalid {
|
|
nextParent = parent
|
|
}
|
|
if err := p.parseElement(t, nextParent); err != nil {
|
|
return err
|
|
}
|
|
case xml.EndElement:
|
|
if strings.EqualFold(t.Name.Local, start.Name.Local) {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *ttmlParser) parseAgentDefinition(start xml.StartElement) error {
|
|
id, ok := attrValue(start.Attr, "id")
|
|
id = strings.TrimSpace(id)
|
|
if !ok || id == "" {
|
|
return p.skipElement(start)
|
|
}
|
|
|
|
agent := ttmlDefinedAgent{
|
|
ID: id,
|
|
Type: strings.ToLower(strings.TrimSpace(attrOrEmpty(start.Attr, "type"))),
|
|
}
|
|
|
|
for {
|
|
token, err := p.decoder.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch t := token.(type) {
|
|
case xml.StartElement:
|
|
if strings.EqualFold(t.Name.Local, "name") {
|
|
name, err := p.collectElementText(t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name = sanitizeTTMLText(name)
|
|
if name != "" && agent.Name == "" {
|
|
agent.Name = name
|
|
}
|
|
continue
|
|
}
|
|
if err := p.skipElement(t); err != nil {
|
|
return err
|
|
}
|
|
case xml.EndElement:
|
|
if strings.EqualFold(t.Name.Local, start.Name.Local) {
|
|
p.definedAgents[agent.ID] = agent
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTimingContext) (ttmlMetadataEntry, bool, error) {
|
|
forKey, hasFor := attrValue(start.Attr, "for")
|
|
forKey = strings.TrimSpace(forKey)
|
|
|
|
pieces, err := p.parseInlineElement(start, parent)
|
|
if err != nil {
|
|
return ttmlMetadataEntry{}, false, err
|
|
}
|
|
if !hasFor || forKey == "" {
|
|
return ttmlMetadataEntry{}, false, nil
|
|
}
|
|
|
|
ctx := p.childContext(start.Attr, parent)
|
|
if ctx.invalid {
|
|
return ttmlMetadataEntry{}, false, nil
|
|
}
|
|
|
|
value, tokens := buildTTMLLineFromPieces(pieces)
|
|
line := Line{Value: value}
|
|
if ctx.hasBegin {
|
|
startMs := ctx.begin
|
|
line.Start = &startMs
|
|
}
|
|
if ctx.hasEnd {
|
|
endMs := ctx.end
|
|
line.End = &endMs
|
|
}
|
|
if len(tokens) > 0 {
|
|
line.Cue = tokens
|
|
}
|
|
line = normalizeLineTiming(line)
|
|
|
|
if line.Value == "" && len(line.Cue) == 0 {
|
|
return ttmlMetadataEntry{}, false, nil
|
|
}
|
|
|
|
return ttmlMetadataEntry{key: forKey, line: line}, true, nil
|
|
}
|
|
|
|
func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, error) {
|
|
var pieces []ttmlPiece
|
|
|
|
for {
|
|
token, err := p.decoder.Token()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
switch t := token.(type) {
|
|
case xml.StartElement:
|
|
inlinePieces, err := p.parseInlineElement(t, parent)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
pieces = append(pieces, inlinePieces...)
|
|
case xml.EndElement:
|
|
if strings.EqualFold(t.Name.Local, "p") {
|
|
value, tokens := buildTTMLLineFromPieces(pieces)
|
|
return value, tokens, nil
|
|
}
|
|
case xml.CharData:
|
|
pieces = append(pieces, ttmlPiece{raw: string(t)})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) {
|
|
local := strings.ToLower(start.Name.Local)
|
|
if local == "br" {
|
|
return []ttmlPiece{{isBreak: true}}, nil
|
|
}
|
|
|
|
ctx := p.childContext(start.Attr, parent)
|
|
_, hasBegin := attrValue(start.Attr, "begin")
|
|
_, hasEnd := attrValue(start.Attr, "end")
|
|
_, hasDur := attrValue(start.Attr, "dur")
|
|
hasOwnTiming := hasBegin || hasEnd || hasDur
|
|
|
|
var pieces []ttmlPiece
|
|
|
|
for {
|
|
token, err := p.decoder.Token()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch t := token.(type) {
|
|
case xml.StartElement:
|
|
inlinePieces, err := p.parseInlineElement(t, ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pieces = append(pieces, inlinePieces...)
|
|
case xml.EndElement:
|
|
if !strings.EqualFold(t.Name.Local, start.Name.Local) {
|
|
continue
|
|
}
|
|
|
|
if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) {
|
|
rawValue := concatTTMLPieceRaw(pieces)
|
|
tokenText := sanitizeTTMLText(rawValue)
|
|
if tokenText != "" {
|
|
parsedToken := Cue{
|
|
AgentID: p.resolveCueAgentID(ctx),
|
|
}
|
|
if ctx.hasBegin {
|
|
startMs := ctx.begin
|
|
parsedToken.Start = &startMs
|
|
}
|
|
if ctx.hasEnd {
|
|
endMs := ctx.end
|
|
parsedToken.End = &endMs
|
|
}
|
|
|
|
return []ttmlPiece{{
|
|
raw: rawValue,
|
|
cue: &parsedToken,
|
|
}}, nil
|
|
}
|
|
}
|
|
|
|
return pieces, nil
|
|
case xml.CharData:
|
|
pieces = append(pieces, ttmlPiece{raw: string(t)})
|
|
}
|
|
}
|
|
}
|
|
|
|
func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) {
|
|
finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces))
|
|
for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 {
|
|
finalized = finalized[1:]
|
|
}
|
|
for len(finalized) > 0 {
|
|
last := finalized[len(finalized)-1]
|
|
if last.text != "" || len(last.cues) > 0 {
|
|
break
|
|
}
|
|
finalized = finalized[:len(finalized)-1]
|
|
}
|
|
|
|
var value strings.Builder
|
|
cues := make([]Cue, 0, 8)
|
|
byteOffset := 0
|
|
for i, line := range finalized {
|
|
if i > 0 {
|
|
value.WriteByte('\n')
|
|
byteOffset++
|
|
}
|
|
value.WriteString(line.text)
|
|
for _, cue := range line.cues {
|
|
cue.ByteStart += byteOffset
|
|
cue.ByteEnd += byteOffset
|
|
cues = append(cues, cue)
|
|
}
|
|
byteOffset += len(line.text)
|
|
}
|
|
|
|
return value.String(), cues
|
|
}
|
|
|
|
type ttmlFinalLine struct {
|
|
text string
|
|
cues []Cue
|
|
}
|
|
|
|
func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine {
|
|
finalized := make([]ttmlFinalLine, 0, len(lines))
|
|
for _, line := range lines {
|
|
text, cues := finalizeTTMLLogicalLine(line)
|
|
finalized = append(finalized, ttmlFinalLine{text: text, cues: cues})
|
|
}
|
|
return finalized
|
|
}
|
|
|
|
func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece {
|
|
lines := [][]ttmlPiece{{}}
|
|
prevEndedWithSpace := true // leading whitespace on a fresh line is dropped
|
|
for _, piece := range pieces {
|
|
if piece.isBreak {
|
|
lines = append(lines, []ttmlPiece{})
|
|
prevEndedWithSpace = true
|
|
continue
|
|
}
|
|
|
|
raw := normalizeTTMLPieceRaw(piece.raw)
|
|
// Collapse whitespace across piece boundaries: a piece's leading space is
|
|
// redundant when the text emitted so far already ends with one.
|
|
if prevEndedWithSpace {
|
|
raw = strings.TrimPrefix(raw, " ")
|
|
}
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{
|
|
raw: raw,
|
|
cue: gg.Clone(piece.cue),
|
|
})
|
|
prevEndedWithSpace = strings.HasSuffix(raw, " ")
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) {
|
|
rawLine := concatTTMLPieceRaw(line)
|
|
if rawLine == "" {
|
|
return "", nil
|
|
}
|
|
|
|
leftTrimBytes := len(rawLine) - len(strings.TrimLeftFunc(rawLine, unicode.IsSpace))
|
|
rightTrimBytes := len(rawLine) - len(strings.TrimRightFunc(rawLine, unicode.IsSpace))
|
|
trimmedEnd := len(rawLine) - rightTrimBytes
|
|
if trimmedEnd < leftTrimBytes {
|
|
trimmedEnd = leftTrimBytes
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(rawLine)
|
|
cues := make([]Cue, 0, len(line))
|
|
cursor := 0
|
|
for _, piece := range line {
|
|
pieceEnd := cursor + len(piece.raw)
|
|
if piece.cue != nil {
|
|
byteStart := max(cursor, leftTrimBytes)
|
|
byteEnd := min(pieceEnd, trimmedEnd)
|
|
if byteStart < byteEnd {
|
|
cue := *piece.cue
|
|
cue.Value = rawLine[byteStart:byteEnd]
|
|
cue.ByteStart = byteStart - leftTrimBytes
|
|
cue.ByteEnd = byteEnd - leftTrimBytes - 1
|
|
cues = append(cues, cue)
|
|
}
|
|
}
|
|
cursor = pieceEnd
|
|
}
|
|
|
|
return trimmed, cues
|
|
}
|
|
|
|
// normalizeTTMLPieceRaw collapses whitespace following TTML's default mode
|
|
// (xml:space="default", the root default per TTML2 §8.1.1): per §8.2.10 that
|
|
// means linefeed-treatment="treat-as-space" and white-space-collapse="true", so
|
|
// linefeeds and other whitespace runs collapse to a single space. Collapsing is
|
|
// applied unconditionally; xml:space="preserve" is not supported (no lyric
|
|
// source in practice relies on it). Hard line breaks come only from <br/>
|
|
// (§8.1.7), tracked separately via ttmlPiece.isBreak, so pretty-printed
|
|
// indentation between elements does not inject spurious newlines.
|
|
func normalizeTTMLPieceRaw(raw string) string {
|
|
raw = str.SanitizeText(raw)
|
|
return collapseTTMLWhitespace(raw)
|
|
}
|
|
|
|
func collapseTTMLWhitespace(raw string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(raw))
|
|
prevSpace := false
|
|
for _, r := range raw {
|
|
// Only the XML S production (space, tab, CR, LF) is collapsible whitespace.
|
|
// Other Unicode spaces (e.g. NBSP, U+3000) are content characters, not
|
|
// whitespace, so they pass through unchanged.
|
|
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
|
|
if !prevSpace {
|
|
b.WriteByte(' ')
|
|
prevSpace = true
|
|
}
|
|
continue
|
|
}
|
|
b.WriteRune(r)
|
|
prevSpace = false
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func concatTTMLPieceRaw(pieces []ttmlPiece) string {
|
|
var raw strings.Builder
|
|
for _, piece := range pieces {
|
|
raw.WriteString(normalizeTTMLPieceRaw(piece.raw))
|
|
}
|
|
return raw.String()
|
|
}
|
|
|
|
func ttmlPiecesContainCue(pieces []ttmlPiece) bool {
|
|
for _, piece := range pieces {
|
|
if piece.cue != nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (p *ttmlParser) toLyricList() LyricList {
|
|
res := make(LyricList, 0, len(p.mainLangOrder)+len(p.translationLangOrder)+len(p.pronunciationLangOrder))
|
|
for _, lang := range p.mainLangOrder {
|
|
lines := p.mainLinesByLang[lang]
|
|
if len(lines) == 0 {
|
|
continue
|
|
}
|
|
res = append(res, p.finalizeLyrics(Lyrics{
|
|
Kind: LyricKindMain,
|
|
Lang: lang,
|
|
Line: lines,
|
|
Synced: linesAreSynced(lines),
|
|
}))
|
|
}
|
|
|
|
res = append(res, p.buildMetadataLyrics(LyricKindTranslation, p.translationLangOrder, p.translationEntriesByLg)...)
|
|
res = append(res, p.buildMetadataLyrics(LyricKindPronunciation, p.pronunciationLangOrder, p.pronunciationEntriesByLg)...)
|
|
return res
|
|
}
|
|
|
|
func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entriesByLang map[string][]ttmlMetadataEntry) LyricList {
|
|
res := make(LyricList, 0, len(langOrder))
|
|
|
|
for _, lang := range langOrder {
|
|
entries := entriesByLang[lang]
|
|
if len(entries) == 0 {
|
|
continue
|
|
}
|
|
|
|
seenKeys := make(map[string]struct{}, len(entries))
|
|
resolved := make([]ttmlResolvedMetadataLine, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if _, exists := seenKeys[entry.key]; exists {
|
|
continue
|
|
}
|
|
seenKeys[entry.key] = struct{}{}
|
|
|
|
ref, ok := p.mainLineRefsByKey[entry.key]
|
|
if !ok {
|
|
log.Warn("Skipping TTML metadata line without matching key", "kind", kind, "lang", lang, "key", entry.key)
|
|
continue
|
|
}
|
|
|
|
line := entry.line
|
|
if line.Start == nil && ref.line.Start != nil {
|
|
startMs := *ref.line.Start
|
|
line.Start = &startMs
|
|
}
|
|
if line.End == nil && ref.line.End != nil {
|
|
endMs := *ref.line.End
|
|
line.End = &endMs
|
|
}
|
|
line = normalizeLineTiming(line)
|
|
|
|
if line.Value == "" && len(line.Cue) == 0 {
|
|
continue
|
|
}
|
|
|
|
resolved = append(resolved, ttmlResolvedMetadataLine{
|
|
order: ref.order,
|
|
seq: entry.seq,
|
|
line: line,
|
|
})
|
|
}
|
|
|
|
if len(resolved) == 0 {
|
|
continue
|
|
}
|
|
|
|
sort.SliceStable(resolved, func(i, j int) bool {
|
|
if resolved[i].order != resolved[j].order {
|
|
return resolved[i].order < resolved[j].order
|
|
}
|
|
return resolved[i].seq < resolved[j].seq
|
|
})
|
|
|
|
lines := make([]Line, len(resolved))
|
|
for i := range resolved {
|
|
lines[i] = resolved[i].line
|
|
}
|
|
|
|
res = append(res, p.finalizeLyrics(Lyrics{
|
|
Kind: kind,
|
|
Lang: lang,
|
|
Line: lines,
|
|
Synced: linesAreSynced(lines),
|
|
}))
|
|
}
|
|
|
|
return res
|
|
}
|
|
|
|
func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics {
|
|
lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line)
|
|
return normalizeLyrics(lyrics)
|
|
}
|
|
|
|
func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) {
|
|
if len(lines) == 0 {
|
|
return lines, nil
|
|
}
|
|
|
|
usedOrder := make([]string, 0, 4)
|
|
usedSet := make(map[string]struct{}, 4)
|
|
sawEmptyCue := false
|
|
|
|
for i := range lines {
|
|
for j := range lines[i].Cue {
|
|
agentID := strings.TrimSpace(lines[i].Cue[j].AgentID)
|
|
if agentID == "" {
|
|
sawEmptyCue = true
|
|
continue
|
|
}
|
|
if _, exists := usedSet[agentID]; !exists {
|
|
usedSet[agentID] = struct{}{}
|
|
usedOrder = append(usedOrder, agentID)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(usedOrder) == 0 {
|
|
return lines, nil
|
|
}
|
|
|
|
mainID := ""
|
|
for _, agentID := range usedOrder {
|
|
role := p.baseRoleForAgent(agentID)
|
|
if role != "bg" && role != "group" {
|
|
mainID = agentID
|
|
break
|
|
}
|
|
}
|
|
if mainID == "" && sawEmptyCue {
|
|
mainID = "main"
|
|
}
|
|
if mainID == "" {
|
|
for _, agentID := range usedOrder {
|
|
if p.baseRoleForAgent(agentID) != "bg" {
|
|
mainID = agentID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if mainID == "" {
|
|
mainID = usedOrder[0]
|
|
}
|
|
|
|
if _, exists := usedSet[mainID]; !exists {
|
|
usedSet[mainID] = struct{}{}
|
|
usedOrder = append([]string{mainID}, usedOrder...)
|
|
}
|
|
|
|
for i := range lines {
|
|
for j := range lines[i].Cue {
|
|
if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" {
|
|
lines[i].Cue[j].AgentID = mainID
|
|
}
|
|
}
|
|
}
|
|
|
|
agents := make([]Agent, 0, len(usedOrder))
|
|
for _, agentID := range usedOrder {
|
|
role := p.baseRoleForAgent(agentID)
|
|
if agentID == mainID {
|
|
role = "main"
|
|
}
|
|
agent := Agent{
|
|
ID: agentID,
|
|
Role: role,
|
|
Name: p.agentNameForID(agentID),
|
|
}
|
|
agents = append(agents, agent)
|
|
}
|
|
|
|
return lines, agents
|
|
}
|
|
|
|
func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string {
|
|
agentID := strings.TrimSpace(ctx.agentID)
|
|
if contextHasRole(ctx.role, "x-bg") {
|
|
if agentID == "" {
|
|
agentID = "main"
|
|
}
|
|
return backgroundAgentID(agentID)
|
|
}
|
|
return agentID
|
|
}
|
|
|
|
func (p *ttmlParser) baseRoleForAgent(agentID string) string {
|
|
if isBackgroundAgentID(agentID) {
|
|
return "bg"
|
|
}
|
|
|
|
if agent, ok := p.definedAgents[agentID]; ok {
|
|
switch agent.Type {
|
|
case "group":
|
|
return "group"
|
|
default:
|
|
return "voice"
|
|
}
|
|
}
|
|
|
|
return "voice"
|
|
}
|
|
|
|
func (p *ttmlParser) agentNameForID(agentID string) string {
|
|
if isBackgroundAgentID(agentID) {
|
|
baseID := strings.TrimPrefix(agentID, ttmlBackgroundAgentPrefix)
|
|
if baseID == "main" {
|
|
return ""
|
|
}
|
|
if agent, ok := p.definedAgents[baseID]; ok {
|
|
return agent.Name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
if agent, ok := p.definedAgents[agentID]; ok {
|
|
return agent.Name
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func backgroundAgentID(agentID string) string {
|
|
return ttmlBackgroundAgentPrefix + agentID
|
|
}
|
|
|
|
func isBackgroundAgentID(agentID string) bool {
|
|
return strings.HasPrefix(agentID, ttmlBackgroundAgentPrefix)
|
|
}
|
|
|
|
func contextHasRole(roles string, role string) bool {
|
|
lowerRole := strings.ToLower(role)
|
|
for _, candidate := range strings.Fields(strings.ToLower(roles)) {
|
|
if candidate == lowerRole {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func appendTTMLRoles(existing string, roles string) string {
|
|
for _, role := range strings.Fields(roles) {
|
|
if contextHasRole(existing, role) {
|
|
continue
|
|
}
|
|
if existing == "" {
|
|
existing = role
|
|
} else {
|
|
existing += " " + role
|
|
}
|
|
}
|
|
return existing
|
|
}
|
|
|
|
func (p *ttmlParser) addMainLine(lang string, lineKey string, line Line) {
|
|
lang = normalizeLyricLang(lang)
|
|
if _, ok := p.mainLinesByLang[lang]; !ok {
|
|
p.mainLangOrder = append(p.mainLangOrder, lang)
|
|
}
|
|
p.mainLinesByLang[lang] = append(p.mainLinesByLang[lang], line)
|
|
|
|
lineKey = strings.TrimSpace(lineKey)
|
|
if lineKey != "" {
|
|
if _, exists := p.mainLineRefsByKey[lineKey]; !exists {
|
|
p.mainLineRefsByKey[lineKey] = ttmlLineRef{
|
|
order: p.mainLineOrder,
|
|
line: line,
|
|
}
|
|
}
|
|
}
|
|
p.mainLineOrder++
|
|
}
|
|
|
|
func (p *ttmlParser) addMetadataEntry(kind string, lang string, entry ttmlMetadataEntry) {
|
|
lang = normalizeLyricLang(lang)
|
|
entry.seq = p.metadataSeq
|
|
p.metadataSeq++
|
|
|
|
switch kind {
|
|
case LyricKindTranslation:
|
|
if _, ok := p.translationEntriesByLg[lang]; !ok {
|
|
p.translationLangOrder = append(p.translationLangOrder, lang)
|
|
}
|
|
p.translationEntriesByLg[lang] = append(p.translationEntriesByLg[lang], entry)
|
|
case LyricKindPronunciation:
|
|
if _, ok := p.pronunciationEntriesByLg[lang]; !ok {
|
|
p.pronunciationLangOrder = append(p.pronunciationLangOrder, lang)
|
|
}
|
|
p.pronunciationEntriesByLg[lang] = append(p.pronunciationEntriesByLg[lang], entry)
|
|
}
|
|
}
|
|
|
|
func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) ttmlTimingContext {
|
|
ctx := parent
|
|
|
|
if lang, ok := attrValue(attrs, "lang"); ok {
|
|
ctx.lang = normalizeLyricLang(lang)
|
|
}
|
|
if agentID, ok := attrValue(attrs, "agent"); ok {
|
|
ctx.agentID = strings.TrimSpace(agentID)
|
|
}
|
|
if role, ok := attrValue(attrs, "role"); ok {
|
|
role = strings.TrimSpace(role)
|
|
if role != "" {
|
|
ctx.role = appendTTMLRoles(ctx.role, role)
|
|
}
|
|
}
|
|
|
|
beginExpr, hasBegin := attrValue(attrs, "begin")
|
|
endExpr, hasEnd := attrValue(attrs, "end")
|
|
durExpr, hasDur := attrValue(attrs, "dur")
|
|
|
|
if hasBegin {
|
|
begin, kind, ok := parseTTMLTimeExpression(beginExpr, p.params)
|
|
if !ok {
|
|
ctx.invalid = true
|
|
return ctx
|
|
}
|
|
|
|
base := int64(0)
|
|
if parent.hasBegin {
|
|
base = parent.begin
|
|
}
|
|
ctx.begin = resolveTTMLTime(begin, kind, base, parent)
|
|
ctx.hasBegin = true
|
|
} else {
|
|
ctx.begin = parent.begin
|
|
ctx.hasBegin = parent.hasBegin
|
|
}
|
|
|
|
var calculatedEnd int64
|
|
calculatedHasEnd := false
|
|
|
|
if hasEnd {
|
|
end, kind, ok := parseTTMLTimeExpression(endExpr, p.params)
|
|
if !ok {
|
|
ctx.invalid = true
|
|
return ctx
|
|
}
|
|
|
|
base := ctx.begin
|
|
if !ctx.hasBegin {
|
|
base = parent.begin
|
|
}
|
|
calculatedEnd = resolveTTMLTime(end, kind, base, parent)
|
|
calculatedHasEnd = true
|
|
}
|
|
|
|
if hasDur {
|
|
dur, ok := parseTTMLDurationExpression(durExpr, p.params)
|
|
if !ok {
|
|
ctx.invalid = true
|
|
return ctx
|
|
}
|
|
if ctx.hasBegin {
|
|
durEnd := ctx.begin + dur
|
|
if !calculatedHasEnd || durEnd < calculatedEnd {
|
|
calculatedEnd = durEnd
|
|
calculatedHasEnd = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !calculatedHasEnd && parent.hasEnd {
|
|
calculatedEnd = parent.end
|
|
calculatedHasEnd = true
|
|
}
|
|
|
|
ctx.end = calculatedEnd
|
|
ctx.hasEnd = calculatedHasEnd
|
|
return ctx
|
|
}
|
|
|
|
func (p *ttmlParser) updateTimingParams(attrs []xml.Attr) {
|
|
frameRate := p.params.frameRate
|
|
if value, ok := attrValue(attrs, "frameRate"); ok {
|
|
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
|
|
frameRate = parsed
|
|
}
|
|
}
|
|
|
|
if value, ok := attrValue(attrs, "frameRateMultiplier"); ok {
|
|
parts := strings.Fields(value)
|
|
if len(parts) == 2 {
|
|
numerator, errA := strconv.ParseFloat(parts[0], 64)
|
|
denominator, errB := strconv.ParseFloat(parts[1], 64)
|
|
if errA == nil && errB == nil && denominator > 0 {
|
|
frameRate = frameRate * (numerator / denominator)
|
|
}
|
|
}
|
|
}
|
|
|
|
subFrameRate := p.params.subFrameRate
|
|
if value, ok := attrValue(attrs, "subFrameRate"); ok {
|
|
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
|
|
subFrameRate = parsed
|
|
}
|
|
}
|
|
|
|
tickRate := p.params.tickRate
|
|
if value, ok := attrValue(attrs, "tickRate"); ok {
|
|
if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 {
|
|
tickRate = parsed
|
|
}
|
|
}
|
|
|
|
p.params.frameRate = gg.If(frameRate > 0, frameRate, defaultTTMLFrameRate)
|
|
p.params.subFrameRate = gg.If(subFrameRate > 0, subFrameRate, defaultTTMLSubFrameRate)
|
|
p.params.tickRate = gg.If(tickRate > 0, tickRate, defaultTTMLTickRate)
|
|
}
|
|
|
|
func parseTTMLDurationExpression(expr string, params ttmlTimingParams) (int64, bool) {
|
|
value, _, ok := parseTTMLTimeExpression(expr, params)
|
|
return value, ok
|
|
}
|
|
|
|
func resolveTTMLTime(value int64, kind ttmlTimeKind, base int64, parent ttmlTimingContext) int64 {
|
|
switch kind {
|
|
case ttmlTimeAbsolute:
|
|
return value
|
|
case ttmlTimeOffset:
|
|
return base + value
|
|
case ttmlTimeAmbiguous:
|
|
absolute := value
|
|
offset := base + value
|
|
|
|
// No parent timing context → no reference frame for offsets.
|
|
// Prefer absolute when offset differs (i.e., base > 0).
|
|
if !parent.hasBegin && !parent.hasEnd && base != 0 {
|
|
return absolute
|
|
}
|
|
|
|
if parent.hasBegin && parent.hasEnd {
|
|
absoluteInParent := absolute >= parent.begin && absolute <= parent.end
|
|
offsetInParent := offset >= parent.begin && offset <= parent.end
|
|
if absoluteInParent && !offsetInParent {
|
|
return absolute
|
|
}
|
|
if offsetInParent && !absoluteInParent {
|
|
return offset
|
|
}
|
|
}
|
|
|
|
if parent.hasBegin {
|
|
if absolute < parent.begin && offset >= parent.begin {
|
|
return offset
|
|
}
|
|
if absolute >= parent.begin && offset > absolute {
|
|
return absolute
|
|
}
|
|
}
|
|
return offset
|
|
default:
|
|
return base + value
|
|
}
|
|
}
|
|
|
|
func parseTTMLTimeExpression(expr string, params ttmlTimingParams) (int64, ttmlTimeKind, bool) {
|
|
expr = strings.TrimSpace(expr)
|
|
if expr == "" {
|
|
return 0, ttmlTimeOffset, false
|
|
}
|
|
|
|
lower := strings.ToLower(expr)
|
|
if strings.Contains(lower, "wallclock(") ||
|
|
strings.Contains(lower, ".begin") ||
|
|
strings.Contains(lower, ".end") {
|
|
log.Warn("Unsupported TTML time expression", "value", expr)
|
|
return 0, ttmlTimeOffset, false
|
|
}
|
|
|
|
// Best-effort support for non-standard TTML seen in the wild where a
|
|
// bare decimal value is used (implicitly seconds), e.g. "0.170".
|
|
if value, err := strconv.ParseFloat(lower, 64); err == nil && value >= 0 {
|
|
return int64(math.Round(value * 1000)), ttmlTimeAmbiguous, true
|
|
}
|
|
|
|
if matches := offsetTimeRegex.FindStringSubmatch(lower); len(matches) == 3 {
|
|
value, err := strconv.ParseFloat(matches[1], 64)
|
|
if err != nil {
|
|
return 0, ttmlTimeOffset, false
|
|
}
|
|
|
|
unit := matches[2]
|
|
seconds := 0.0
|
|
switch unit {
|
|
case "h":
|
|
seconds = value * 60 * 60
|
|
case "m":
|
|
seconds = value * 60
|
|
case "s":
|
|
seconds = value
|
|
case "ms":
|
|
seconds = value / 1000
|
|
case "f":
|
|
seconds = value / params.frameRate
|
|
case "t":
|
|
seconds = value / params.tickRate
|
|
default:
|
|
return 0, ttmlTimeOffset, false
|
|
}
|
|
|
|
return int64(math.Round(seconds * 1000)), ttmlTimeOffset, true
|
|
}
|
|
|
|
colonCount := strings.Count(expr, ":")
|
|
switch colonCount {
|
|
case 1, 2:
|
|
clockMs, ok := parseTTMLClockTime(expr)
|
|
if !ok {
|
|
return 0, ttmlTimeAbsolute, false
|
|
}
|
|
return clockMs, ttmlTimeAbsolute, true
|
|
case 3:
|
|
framesMs, ok := parseTTMLFrameTime(expr, params)
|
|
if !ok {
|
|
return 0, ttmlTimeAbsolute, false
|
|
}
|
|
return framesMs, ttmlTimeAbsolute, true
|
|
default:
|
|
log.Warn("Unsupported TTML time expression", "value", expr)
|
|
return 0, ttmlTimeOffset, false
|
|
}
|
|
}
|
|
|
|
func parseTTMLClockTime(value string) (int64, bool) {
|
|
parts := strings.Split(value, ":")
|
|
if len(parts) != 2 && len(parts) != 3 {
|
|
return 0, false
|
|
}
|
|
|
|
hours := int64(0)
|
|
minutesIdx := 0
|
|
if len(parts) == 3 {
|
|
h, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
hours = h
|
|
minutesIdx = 1
|
|
}
|
|
|
|
minutes, err := strconv.ParseInt(parts[minutesIdx], 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
seconds, err := strconv.ParseFloat(parts[minutesIdx+1], 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
totalSeconds := float64(hours*60*60+minutes*60) + seconds
|
|
return int64(math.Round(totalSeconds * 1000)), true
|
|
}
|
|
|
|
func parseTTMLFrameTime(value string, params ttmlTimingParams) (int64, bool) {
|
|
parts := strings.Split(value, ":")
|
|
if len(parts) != 4 {
|
|
return 0, false
|
|
}
|
|
|
|
hours, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
minutes, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
seconds, err := strconv.ParseInt(parts[2], 10, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
frameParts := strings.SplitN(parts[3], ".", 2)
|
|
frames, err := strconv.ParseFloat(frameParts[0], 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
|
|
subFrames := 0.0
|
|
if len(frameParts) == 2 {
|
|
subFrames, err = strconv.ParseFloat(frameParts[1], 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
totalSeconds := float64(hours*60*60 + minutes*60 + seconds)
|
|
totalSeconds += frames / params.frameRate
|
|
totalSeconds += subFrames / (params.subFrameRate * params.frameRate)
|
|
|
|
return int64(math.Round(totalSeconds * 1000)), true
|
|
}
|
|
|
|
func attrValue(attrs []xml.Attr, key string) (string, bool) {
|
|
for _, attr := range attrs {
|
|
if strings.EqualFold(attr.Name.Local, key) {
|
|
return strings.TrimSpace(attr.Value), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func attrOrEmpty(attrs []xml.Attr, key string) string {
|
|
value, _ := attrValue(attrs, key)
|
|
return value
|
|
}
|
|
|
|
func (p *ttmlParser) collectElementText(start xml.StartElement) (string, error) {
|
|
var text strings.Builder
|
|
|
|
for {
|
|
token, err := p.decoder.Token()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
switch t := token.(type) {
|
|
case xml.StartElement:
|
|
value, err := p.collectElementText(t)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
text.WriteString(value)
|
|
case xml.EndElement:
|
|
if strings.EqualFold(t.Name.Local, start.Name.Local) {
|
|
return text.String(), nil
|
|
}
|
|
case xml.CharData:
|
|
text.WriteString(string(t))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *ttmlParser) skipElement(_ xml.StartElement) error {
|
|
depth := 1
|
|
for depth > 0 {
|
|
token, err := p.decoder.Token()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch token.(type) {
|
|
case xml.StartElement:
|
|
depth++
|
|
case xml.EndElement:
|
|
depth--
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeLyricLang(lang string) string {
|
|
lang = strings.ToLower(strings.TrimSpace(lang))
|
|
if lang == "" {
|
|
return "xxx"
|
|
}
|
|
return lang
|
|
}
|
|
|
|
func sanitizeTTMLText(raw string) string {
|
|
raw = str.SanitizeText(raw)
|
|
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
|
raw = strings.ReplaceAll(raw, "\r", "\n")
|
|
|
|
lines := strings.Split(raw, "\n")
|
|
for i := range lines {
|
|
lines[i] = strings.TrimSpace(lines[i])
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
}
|
|
|
|
func linesAreSynced(lines []Line) bool {
|
|
for i := range lines {
|
|
if lines[i].Start != nil {
|
|
return true
|
|
}
|
|
for j := range lines[i].Cue {
|
|
if lines[i].Cue[j].Start != nil {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|