虽然本文中涉及到的关键代码会进行说明,理解本文思路仍然需要AE表达式或JavaScript编程基础。
完成效果预览
卡拉OK所需的逐字信息从何而来——KRC
KRC 是酷狗专用的卡拉OK歌词储存方式,与其他格式相比,结构清晰、直观,便于程序解析,本文使用KRC作为储存卡拉OK信息的格式。
直接获取现成的KRC
下载安装酷狗音乐,播放你需要的曲目,在歌词页面右键,歌词关联,本地关联。

其中显示的所在路径,就是歌词krc文件的位置。

自己手动制作KRC
使用 klm 制作,非常完善的工具,具体操作参考作者b站视频。
解密KRC得到文本格式
KRC的格式
[2095,4758]<0,202,0>我<202,299,0>醉<501,607,0>倚<1108,687,0>楼<1795,270,0>台 <2463,308,0>翁<2771,344,0>头<3115,319,0>请<3434,311,0>酒<3745,314,0>正<4059,353,0>豪<4412,346,0>迈
[7272,4760]<0,258,0>持<258,308,0>盏<566,463,0>行 <1247,325,0>人<1572,318,0>间<1890,357,0>清<2247,306,0>愁<2553,306,0>我<2859,349,0>初<3208,1552,0>开
[12449,4903]<0,316,0>我<316,328,0>下<644,666,0>榻<1310,632,0>山<1942,388,0>海 <2606,338,0>风<2944,318,0>花<3262,316,0>雪<3578,354,0>月<3932,314,0>入<4246,350,0>我<4596,307,0>怀
[17716,4953]<0,308,0>人<308,339,0>生<647,302,0>少<949,333,0>有<1282,329,0>快<1611,338,0>哉 <1949,338,0>何<2287,352,0>须<2639,310,0>论<2949,333,0>成<3282,1671,0>败
[23354,4903]<0,268,0>欠<268,318,0>二<586,331,0>两<917,309,0>笔<1226,368,0>墨<1594,644,0>债 <2553,344,0>闲<2897,318,0>听<3215,334,0>梅<3549,326,0>雨<3875,358,0>窗<4233,670,0>外
[28940,3927]<0,249,0>陋<249,202,0>室<451,523,0>窗<974,313,0>静 <1287,530,0>清<1817,442,0>风<2259,334,0>徐<2593,1334,0>来
[33808,4824]<0,357,0>春<357,331,0>意<688,307,0>浓<995,314,0>上<1309,347,0>眉<1656,646,0>梢 <2984,292,0>细<3276,166,0>雨<3442,541,0>敲<3983,321,0>石<4304,520,0>苔
[38739,4296]<0,304,0>明<304,326,0>月<630,319,0>上<949,155,0>高<1104,595,0>台 <1699,142,0>皎<1841,148,0>皎<1989,311,0>千<2300,172,0>年<2472,516,0>来 <2988,349,0>未<3337,959,0>改
[44066,3061]<0,314,0>锦<314,321,0>绣<635,295,0>词<930,330,0>句<1260,325,0>本<1585,327,0>从<1912,318,0>天<2230,167,0>上<2397,664,0>来
[47127,2165]<0,175,0>狂<175,311,0>写<486,160,0>诗<646,478,0>词<1124,404,0>三<1528,637,0>百
[49674,4543]<0,144,0>如<144,165,0>何<309,265,0>请<574,130,0>这<704,465,0>妙<1169,415,0>笔 <1584,294,0>入<1878,198,0>我<2076,466,0>梦<2542,368,0>中<2910,1633,0>来
行结构
[begin,duration]
begin:这一行开始时刻(毫秒)duration:这一行总持续时间(毫秒)
例如:[270,3840] 表示本行0.27秒开始,持续3.84秒
字结构(逐字时间轴)
<offset,duration,0>字
<offset,duration,0>字
在<offset,duration,0>与<offset,duration,0>之间的多个字符(例如“字 ”,字后跟空格),应被视为同一组,他们共同对应一个持续时间。
三个参数含义:
| 参数 | 含义 |
|---|---|
| offset | 字符开始时刻(相对于本行开始时刻来说,毫秒) |
| duration | 字符持续时间 |
| 0 | 固定为0(保留字段) |
从KRC到卡拉OK动画
用一个文本图层存放krc文本
新建一个文本图层,名称可以改为“KRC文本图层”,“行高”必须设置为固定值,例如字体大小是80px,行高设置为85px。
计算几个参数
整体时间偏移(非必须,建议有,并且最初就要考虑到)
当你的krc中歌词时间与ae合成中音乐时间并不完全对应时,有一个整体偏移量的值可以调整,会非常舒心。只在一处设置这个值,在后续用到的地方调用即可。
新建一个空对象,名称可以改为“控制图层”,在其上添加 表达式控制 - 滑块控制,名字可以改为“时间偏移(毫秒)”。
当前所在行号
思路:[begin,duration] 中begin表示这一行开始的时刻(毫秒)。存在一种情况:上一行已经结束,但是下一行还未开始(begin1+duration1<begin2),这种情况下,不应该算作下一行已经开始,当前所在行号应该保持为上一行,所以,只要当前时刻<某一行的begin时,就表示当前进度处于这一行中。
在前面创建的“控制图层”上,继续添加一个 表达式控制 - 滑块控制,名字可以改为“当前行号”。
写入如下表达式:
function getCurrentLineIndex(lines, currentTimeMs) {
let idx = 0;
for (let i = 0, n = lines.length; i < n; i++) {
const m = lines[i].match(/\[(\d+),\d+\]/);
if (!m) continue;
const lineStartMs = +m[1];
if (lineStartMs <= currentTimeMs) {
idx = i;
} else {
break;
}
}
return idx;
}
const lines = thisComp
.layer("KRC文本图层")
.text.sourceText
.toString()
.split(/\r\n|\r|\n/);
const timeOffset = thisLayer.effect("时间偏移(毫秒)")("滑块");
const currentTimeMs = time * 1000 + timeOffset;
getCurrentLineIndex(lines, currentTimeMs);
例如上面的一段krc文本,歌词共11行,当前所在行号就会在0~10,11个整数之间变化。
当前行开始时间
在前面创建的“控制图层”上,继续添加一个 表达式控制 - 滑块控制,名字可以改为“当前行开始时间”。
const lines = thisComp
.layer("KRC文本图层")
.text.sourceText
.toString()
.split(/\r\n|\r|\n/);
const currentLineIndex = thisLayer.effect("当前行号")("滑块");
let currentLineStart = 0;
if (currentLineIndex >= 0 && currentLineIndex < lines.length) {
const m = lines[currentLineIndex].match(/\[(\d+),\d+\]/);
if (m) {
currentLineStart = +m[1] / 1000;
}
}
currentLineStart
下一行开始时间
在前面创建的“控制图层”上,继续添加一个 表达式控制 - 滑块控制,名字可以改为“下一行开始时间”。
const lines = thisComp
.layer("KRC文本图层")
.text.sourceText
.toString()
.split(/\r\n|\r|\n/);
const currentLineIndex = thisLayer.effect("当前行号")("滑块");
let nextLineStart = thisComp.duration;
if (currentLineIndex + 1 < lines.length) {
const nextM = lines[currentLineIndex + 1].match(/\[(\d+),\d+\]/);
if (nextM) {
nextLineStart = +nextM[1] / 1000;
}
}
nextLineStart
下一行开始时间 默认值设置为 合成总时长,因为当前行号进行到最后一行时,没有下一行了。

制作滚动文本
新建一个文本图层,可以命名为“滚动文本”,在文本-源文本上使用表达式:
var baseStyle = thisComp
.layer("KRC文本图层")
.text.sourceText.style;
var plainText = thisComp
.layer("KRC文本图层")
.text.sourceText
.toString()
.replace(/\[\d+,\d+\]|\<\d+,\d+,\d+\>/g, "");
baseStyle
.setText(plainText)
.setJustification("alignLeft");
// alignCenter alignRight alignLeft
这里我们先强制设置为“左对齐”,不要继承文本图层的对齐方式,因为对齐方式会影响后续卡拉OK动画的处理方式。
控制滚动文本显示的范围
继续在控制图层上新增滑块控制,可以分别命名为“前置滚动文本行数”、“后置滚动文本行数”。它们分别表示,在当前行之前和之后,额外显示多少行的歌词。
- 在滚动文本图层添加一个动画制作工具-不透明度,可以重命名为“显示范围”。
- 不透明度调为0%。
- 范围选择器-高级 中,单位设为索引,依据设为行,模式设为相减。
范围选择器中,结束 设为:
frontCount = Math.floor(thisComp.layer("控制图层").effect("前置滚动文本行数")("滑块"));
backCount = Math.floor(thisComp.layer("控制图层").effect("后置滚动文本行数")("滑块"));
frontCount + backCount + 1;
偏移 设为:
frontCount = Math.floor(thisComp.layer("控制图层").effect("前置滚动文本行数")("滑块"));
curLine = Math.floor(thisComp.layer("控制图层").effect("当前行号")("滑块"));
curLine - frontCount;
此时会发现,行的显示与隐藏是跳变的,在歌曲进行到下一行的时候,上一行直接消失,下一行直接出现,没有过渡动画,原因是我们的“当前行号”是整数,在行切换时是直接跳变的,此时我们就需要一个,在行切换时,由上一行号平滑过渡到下一行号的值。
并且我们还需要一个手动控制的参数——平滑过渡的时长,过渡开始的时间 = 下一行开始时刻 - 平滑过渡的时长。
继续在控制图层上新增滑块控制,可以分别命名为“平滑过渡时长(毫秒)”,滑块中可以填入800,表示过渡时长是800毫秒。
继续在控制图层上新增滑块控制,可以分别命名为“当前行号(平滑)”,滑块中填入以下表达式:
var timeOffsetSec = thisLayer.effect("时间偏移(毫秒)")("滑块") / 1000;
var fadeDurationSec = 0.6;
var currentIndex = Math.floor(thisLayer.effect("当前行号")("滑块"));
var nextLineTime = thisLayer.effect("下一行开始时间")("滑块");
linear(
time + timeOffsetSec,
nextLineTime - fadeDurationSec,
nextLineTime,
currentIndex,
currentIndex + 1
);
滚动文本图层的滚动效果
在滚动文本图层的 变换 - 位置 填入以下表达式:
const ctrl = thisComp.layer("控制图层");
const textLayer = thisComp.layer("KRC文本图层");
const basePos = value;
let lineHeight = textLayer.text.sourceText.style.leading;
if (lineHeight <= 0) {
lineHeight = 100;
}
const smoothLineIndex = ctrl.effect("当前行号(平滑)")("滑块");
[
basePos[0],
basePos[1] - smoothLineIndex * lineHeight
];
这里读取的是KRC文本图层的行高,(自动)会导致获取到错误的行高,所以必须手动设置固定的行高。
制作卡拉OK行
卡拉OK背景行
直接将“滚动文本”图层复制一份,命名为“卡拉OK背景行”,置于“滚动文本”的上层。在其文本动画制作工具 - 显示范围 中,把结束 设置为1,偏移设置为:
frontCount = Math.floor(thisComp.layer("控制图层").effect("前置滚动文本行数")("滑块"));
curLine = thisComp.layer("控制图层").effect("当前行号(平滑)")("滑块");
curLine - frontCount +1;
源文本的表达式改为:
var srcText = thisComp.layer("KRC文本图层").text.sourceText;
var scrollStyle = thisComp.layer("滚动文本").text.sourceText.style;
var srcStyle = srcText.style;
var selfStyle = text.sourceText.style;
var plainText = srcText
.toString()
.replace(/\[\d+,\d+\]|\<\d+,\d+,\d+\>/g, "");
selfStyle
.setText(plainText)
.setFontSize(srcStyle.fontSize)
.setLeading(srcStyle.leading)
.setTracking(srcStyle.tracking)
.setJustification(scrollStyle.justification);
卡拉OK背景行的颜色、描边可以自行设置,对齐方式遵循“滚动文本”图层。
这时我们给卡拉OK行设置一个浅橙色,播放起来就可以看到类似下面的效果。

用于计算线性擦除动画的隐藏层
在这里,我们需要创建一个仅有“线性擦除”效果的隐藏图层,在其上使用表达式计算“过渡完成”属性,再由实际显示的图层读取。整行缩放、逐字缩放等效果,会影响图层宽度,导致计算的“过渡完成”不准确,同时为了方便实现居中、居右或要移动字幕位置的情况,这里单独使用一个隐藏图层来计算过渡完成的百分比。
将这个图层x位置设为0,源文本设置为:
var txtLayer = thisComp.layer("KRC文本图层");
var styleObj = txtLayer.text.sourceText.style;
var lines = txtLayer.text.sourceText.split(/\r\n|\r|\n/);
var lineIndex = Math.floor(
thisComp.layer("控制图层").effect("当前行号")("滑块")
);
var cleanText = "";
if (lineIndex >= 0 && lineIndex < lines.length) {
cleanText = lines[lineIndex].replace(/\[.*?\]|<.*?>/g, "");
}
styleObj.setText(cleanText);
添加一个效果“线性擦除”,擦除角度设为 -90°,“过渡完成”使用以下表达式:
function getTextUnits(s) {
var u = 0;
for (var i = 0; i < s.length; i++) {
var ch = s.charCodeAt(i);
if (ch == 32) {
u += 0.25;
} else if (
(ch >= 0x4E00 && ch <= 0x9FFF) ||
(ch >= 0x3040 && ch <= 0x30FF) ||
(ch >= 0xAC00 && ch <= 0xD7AF) ||
(ch >= 0xFF00 && ch <= 0xFFEF)
) {
u += 1.0;
} else if (ch >= 65 && ch <= 90) {
u += 0.85;
} else if ((ch >= 97 && ch <= 122) || (ch >= 48 && ch <= 57)) {
u += 0.6;
} else {
u += 0.5;
}
}
return u > 0 ? u : 1;
}
var lyricLayer = thisComp.layer("KRC文本图层");
var lyricLines =
lyricLayer.text.sourceText.match(/[^\r\n]+/g) || [];
var currentLineIndex = Math.floor(
thisComp.layer("控制图层").effect("当前行号")("滑块")
);
var timeOffset = thisComp.layer("控制图层").effect("时间偏移(毫秒)")("滑块");
var currentLineRaw = "";
var lineStartTime = 0;
var currentSegmentTiming = [0, 0];
var currentSegmentIndex = 0;
var currentTimeMs = Math.floor(1000 * time) + timeOffset;
if (0 <= currentLineIndex && currentLineIndex < lyricLines.length) {
var lineParts = lyricLines[currentLineIndex].split("]");
if (lineParts[1]) {
currentLineRaw = lineParts[1];
var headerParts = lineParts[0].split("[");
if (1 < headerParts.length) {
lineStartTime =
parseInt(headerParts[1].split(",")[0], 10) || 0;
}
}
}
var segmentParts = currentLineRaw.split("<");
var segmentCount = segmentParts.length - 1;
if (0 < segmentCount) {
for (var i = segmentParts.length - 1; 1 <= i; i--) {
var segTiming = segmentParts[i].split(">")[0].split(",");
if (+segTiming[0] + lineStartTime <= currentTimeMs) {
currentSegmentTiming = segTiming;
currentSegmentIndex = i;
break;
}
}
}
var rect = sourceRectAtTime();
var layerWidth = thisLayer.width;
var textWidth = rect.width;
if (segmentCount <= 0 || layerWidth <= 0) value;
var textLeftPx = thisLayer.anchorPoint[0] + rect.left;
var totalUnits = 0;
var startUnits = 0;
var endUnits = 0;
for (var j = 1; j < segmentParts.length; j++) {
var segText = segmentParts[j].split(">")[1] || "";
var segUnits = getTextUnits(segText);
totalUnits += segUnits;
if (j < currentSegmentIndex) {
startUnits += segUnits;
}
if (j <= currentSegmentIndex) {
endUnits += segUnits;
}
}
if (totalUnits <= 0) value;
var x1 = textLeftPx + textWidth * (startUnits / totalUnits);
var x2 = textLeftPx + textWidth * (endUnits / totalUnits);
linear(
currentTimeMs,
lineStartTime + (+currentSegmentTiming[0] || 0),
lineStartTime + (+currentSegmentTiming[0] || 0) + (+currentSegmentTiming[1] || 0),
100 - (clamp(x1, 0, layerWidth) / layerWidth) * 100,
100 - (clamp(x2, 0, layerWidth) / layerWidth) * 100
);
卡拉OK的字幕是从完全消失→逐渐出现变化的,所以对于一行歌词来说,初始的状态“过渡完成”是100%,随着数值减小,卡拉OK字幕从左到右逐渐出现。
值得特别说明的是,“过渡完成”的百分比数值,不是相对于该图层的大小来说的,而是相对于合成尺寸来说的。当图层位置x设为0,擦除角度-90°时,100%对应全部消失,0%对应到合成最右侧全部出现。
我们的一行歌词,并没有整个合成那么宽,所以对于一行歌词,“过渡完成”的变化范围应该是100%→nn%,而不是到0%。上面的表达式,进行了这个计算。



例如上面这一句,75%时,就已经完整显示了。
卡拉OK演唱行
复制一份上面制作好的“卡拉OK背景行”,重命名为“卡拉OK演唱行”。
源文本表达式如下:
var srcText = thisComp.layer("KRC文本图层").text.sourceText
.toString()
.replace(/\[\d+,\d+\]|\<\d+,\d+,\d+\>/g, "");
var scrollStyle = thisComp.layer("滚动文本").text.sourceText.style;
var srcStyle = thisComp.layer("KRC文本图层").text.sourceText.style;
var selfStyle = text.sourceText.style;
var cur = thisProperty.style;
cur
.setText(srcText)
.setFontSize(srcStyle.fontSize)
.setLeading(srcStyle.leading)
.setTracking(srcStyle.tracking)
.setJustification(scrollStyle.justification);
文字动画制作工具 - 显示范围中,将偏移的表达式清除,设为0,起始设为以下表达式:
thisComp.layer("控制图层").effect("当前行号(平滑)")("滑块");
结束设为以下表达式:
Math.floor(thisComp.layer("控制图层").effect("当前行号")("滑块"))+1;
线性擦除的“过渡完成”设为以下表达式:
thisComp.layer("计算过渡完成").effect("线性擦除")("过渡完成") - 50

Tips:用于计算线性擦除动画的隐藏层位于合成最左侧(x=0),例如卡拉OK动画的“过渡完成”过程是100%→75%。但是实际显示的演唱层,位于合成中部(x=合成宽度*50%),正确的“过渡完成”过程应该是50%→25%,所以这里的表达式中有“”。
如果演唱层、背景层和滚动歌词都移动到最左侧,也就不需要“”了。

进阶自定义内容
到这里,我们已经实现了开头展示的视频的效果,下面是一些进阶的内容。
关于平滑过渡 linear / ease / 自定义贝塞尔曲线
如果你觉得滚动的时候linear过于生硬,可以换成ae自带的ease,或者自定义贝塞尔曲线。
将“当前行号(平滑)”的表达式换为以下内容即可:
function bezierCoord(t, p0, p1, p2, p3) {
var u = 1 - t;
return u*u*u*p0 + 3*u*u*t*p1 + 3*u*t*t*p2 + t*t*t*p3;
}
function cubicBezierEase(x, x1, y1, x2, y2) {
var t = x;
for (var i = 0; i < 5; i++) {
var xEstimate = bezierCoord(t, 0, x1, x2, 1);
var dx = xEstimate - x;
if (Math.abs(dx) < 0.0001) break;
var slope =
3 * (1 - t) * (1 - t) * (x1 - 0) +
6 * (1 - t) * t * (x2 - x1) +
3 * t * t * (1 - x2);
if (Math.abs(slope) < 0.0001) break;
t -= dx / slope;
t = Math.max(0, Math.min(1, t));
}
return bezierCoord(t, 0, y1, y2, 1);
}
var timeOffsetSec = thisLayer.effect("时间偏移(毫秒)")("滑块") / 1000;
var fadeDurationSec = thisLayer.effect("平滑过渡时长(毫秒)")("滑块") / 1000;
var currentIndex = Math.floor(thisLayer.effect("当前行号")("滑块"));
var nextLineTime = thisLayer.effect("下一行开始时间")("滑块");
var t = time + timeOffsetSec;
var startT = nextLineTime - fadeDurationSec;
var endT = nextLineTime;
if (t <= startT) {
currentIndex;
} else if (t >= endT) {
currentIndex + 1;
} else {
var p = (t - startT) / (endT - startT);
// 此处修改曲线参数
var eased = cubicBezierEase(p, 0.5, 0.1, 0.1, 1.0);
currentIndex + eased;
}
曲线参数可以在这个网站尝试和预览


