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(``)) // 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
// (§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 }