\n`; for (const word of line.text) { const wBegin = AmLyrics.formatTimestampTTML(word.timestamp); const wEnd = AmLyrics.formatTimestampTTML(word.endtime); // Escape special characters in text const text = word.text .replace(/&/g, '&') .replace(//g, '>'); ttml += ` ${text}\n`; } ttml += '
\n'; } if (this.lyrics.length > 0) { ttml += '${line.backgroundText!.map((syllable, syllableIndex) => { const startTimeMs = syllable.timestamp; const endTimeMs = syllable.endtime; const durationMs = endTimeMs - startTimeMs; const bgRomanizedText = this.showRomanization && syllable.romanizedText && syllable.romanizedText.trim() !== syllable.text.trim() ? html`${syllable.romanizedText}` : ''; return html`${syllable.text}${bgRomanizedText}`; })}
` : ''; // Background vocals share the same line.translation and line.romanizedText // as the main vocal, so we intentionally do NOT render a separate // translation/romanization block for background — it would just duplicate // the main line's text. const bgPlacement = hasBackground ? AmLyrics.getBackgroundTextPlacement(line) : 'after'; const lineData = this.cachedLineData?.[lineIndex]; const wordGroups = lineData?.wordGroups ?? []; const groupGrowable = lineData?.groupGrowable ?? []; const groupGlowing = lineData?.groupGlowing ?? []; const groupCharRise = lineData?.groupCharRise ?? []; const vwFullText = lineData?.vwFullText ?? []; const vwFullDuration = lineData?.vwFullDuration ?? []; const vwCharOffset = lineData?.vwCharOffset ?? []; const vwStartMs = lineData?.vwStartMs ?? []; const vwEndMs = lineData?.vwEndMs ?? []; const lineIsRTL = lineData?.lineIsRTL ?? false; // Create main vocals using YouLyPlus syllable structure const mainVocalElement = html`${wordGroups.map((group, groupIdx) => { const isGrowable = groupGrowable[groupIdx]; const isGlowing = groupGlowing[groupIdx]; const isCharRise = groupCharRise[groupIdx]; const isAnimatedByChar = isGrowable || isCharRise; const groupLineSynced = group.some(s => s.lineSynced); const wordText = isAnimatedByChar ? vwFullText[groupIdx] : ''; const wordDuration = isAnimatedByChar ? vwFullDuration[groupIdx] : 0; const wordNumChars = wordText.length; const groupCharOffset = isAnimatedByChar ? vwCharOffset[groupIdx] : 0; const virtualWordId = isAnimatedByChar ? `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}` : ''; const virtualWordStart = isAnimatedByChar ? vwStartMs[groupIdx] : ''; const virtualWordEnd = isAnimatedByChar ? vwEndMs[groupIdx] : ''; let sylCharAccumulator = 0; const groupText = group.map(s => s.text).join(''); const shouldAllowBreak = groupText.trim().length >= 16 || /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test( groupText, ); // Calculate dynamic rise duration based on the audio duration of the word const wordStartTimeMs = group[0].timestamp; const wordEndTimeMs = group[group.length - 1].endtime; const actualDurationMs = wordEndTimeMs - wordStartTimeMs; // Base float is 0.8s, plus a portion of the audio duration, capped between 1.0s and 2.5s const riseDuration = Math.max( 1.2, Math.min(2.5, 1.2 + (actualDurationMs / 1000) * 0.6), ); return html`${group.map((syllable, sylIdx) => { const startTimeMs = syllable.timestamp; const endTimeMs = syllable.endtime; const durationMs = endTimeMs - startTimeMs; const text = syllable.text || ''; const romanizedText = this.showRomanization && syllable.romanizedText && syllable.romanizedText.trim() !== syllable.text.trim() ? html`${syllable.romanizedText}` : ''; let syllableContent: any = text; if (isAnimatedByChar) { let charIndexInsideSyllable = 0; const numCharsInSyllable = text.replace(/\s/g, '').length || 1; syllableContent = html`${text.split('').map(char => { if (char === ' ') return ' '; const charIndexInsideWord = groupCharOffset + sylCharAccumulator; const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable; sylCharAccumulator += 1; charIndexInsideSyllable += 1; const minDuration = 400; const maxDuration = 3000; const easingPower = 3; const progress = Math.min( 1, Math.max( 0, (wordDuration - minDuration) / (maxDuration - minDuration), ), ); const easedProgress = progress ** easingPower; const isLongWord = wordNumChars > 5; const isShortDuration = wordDuration < 1200; let maxDecayRate = 0; if (isLongWord || isShortDuration) { let decayStrength = 0; if (isLongWord) decayStrength += Math.min((wordNumChars - 5) / 5, 1.0) * 0.4; if (isShortDuration && wordNumChars > 3) decayStrength += Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.3; else if (isShortDuration && wordNumChars <= 3) decayStrength += Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.1; maxDecayRate = Math.min(decayStrength, 0.7); } const positionInWord = wordNumChars > 1 ? charIndexInsideWord / (wordNumChars - 1) : 0; const decayFactor = 1.0 - positionInWord * maxDecayRate; const charProgress = easedProgress * decayFactor; const baseGrowth = wordNumChars <= 3 ? 0.05 : 0.04; const charMaxScale = 1.0 + baseGrowth + charProgress * 0.08; const glowDurFactor = Math.min(1.1, wordDuration / 1500); let glowLenFactor = 1.0; if (wordNumChars <= 3) { glowLenFactor = 0.85; } else if (wordNumChars >= 6) { glowLenFactor = 1.1; } const glowIntensityScale = glowDurFactor * glowLenFactor; const charShadowIntensity = isGlowing ? (0.35 + charProgress * 0.45) * glowIntensityScale : 0; const normalizedGrowth = (charMaxScale - 1.0) / 0.1; const effectiveDuration = (wordDuration + durationMs * 2) / 3; const peakMultiplier = Math.min( 1, Math.max(0.3, effectiveDuration / 2000), ); const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak const position = (charIndexInsideWord + 0.5) / wordNumChars; const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25); return html`${char}`; })}`; } return html`${syllableContent}${romanizedText}`; })}`; })}
`; // Translation container (if enabled) // Hide translation if it matches the original line text const fullLineText = line.text .map(s => s.text) .join('') .trim(); const translationElement = this.showTranslation && line.translation && line.translation.trim() !== fullLineText ? html`