한글 타이핑 자막을 익스프레션으로 만들기
After Effect Blender expression Premiere 모션 그래픽 템플릿 애프터이펙트 익스프레션 프리미어
영상을 작업하다보면 꼭 한 번씩 타이핑 자막을 구현하고 싶을 때가 생깁니다. 그런데 한글 타이핑 자막을 키프레임 애니메이션으로 구현하는 것은 노가다에 가깝습니다. 익스프레션으로 한글 타이핑 자막을 구현해 보겠습니다.
과거에 구현했던 한글 타이핑 자막
먼저 기본적인 한글 타이핑 자막은 일전에 구현한 적이 있습니다.
//일전에 구현했던 타이핑 자막 코드
//RenderText 레이어의 소스텍스트 부분에 넣어주세요.
let cCho = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"],
cJung = ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"],
cJong = ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"];
let toStringFromCharCode = (cho, jung, jong) =>
String.fromCharCode(44032 + cho * 588 + jung * 28 + (jong || 0));
let seperateChar = (char) => {
let cCode = char.charCodeAt() - 0xac00;
let jong = cCode % 28;
let jung = ((cCode - jong) / 28) % 21;
let cho = ((cCode - jong) / 28 - jung) / 21;
return []
.concat(cCho[cho])
.concat(toStringFromCharCode(cho, jung))
.concat(cJong[jong] !== "" ? toStringFromCharCode(cho, jung, jong) : []);
};
let toKorChars = (char) => {
let cCode = char.charCodeAt();
return [].concat(
(!cCode && []) ||
(cCode === 32 && " ") ||
((cCode < 0xac00 || cCode > 0xd7a3) && char) ||
seperateChar(char)
);
};
let typing = (str) =>
str
.split("")
.map((char) => toKorChars(char))
.reduce(
(pre, cur) =>
pre.concat(
cur.map((v) => (pre.length !== 0 ? pre[pre.length - 1] : "") + v)
),
[""]
);
let p = thisComp.layer("RenderText_Control").effect("Speed")("Slider");
const startTime = thisComp.layer("RenderText_Control").effect("StartTime")("Slider");
const totalText = thisComp.layer("Text").text.sourceText;
let strList = typing(totalText);
if (time > startTime) {
let adjustedTime = time - startTime;
let strListIndex = Math.round(adjustedTime*p);
[strList[strList.length > strListIndex ? strListIndex : strList.length-1]];
} else {
[]
}
여기에 대한 내용은 상당히 복잡하기 때문에 자세한 설명은 생략하겠습니다. 그냥 사용하시면 될 것 같습니다.
간단하게 설명하면 한글을 초성, 중성, 종성으로 구분하고 유니코드 값을 이용해서 입력한 한글을 초성, 중성, 종성으로 분리해서 배열로 바꿔줍니다.
그리고 타이핑 함수를 이용해서 [“”, “ㄱ”, “가”, “각”, …] 처럼 단계별로 완성되는 문자열 배열로 만들어주고 그 배열의 원소를 시간에 맞춰서 차례로 출력해줍니다.
지금 이 코드 만으로 이미 한글 타이핑 자막은 완성입니다.

RenderText_Control 레이어에는 Slider Control을 두 개 추가합니다.

Speed는 초당 타이핑 수를 의미합니다. StartTime은 몇 초 뒤에 타이핑을 시작할 것인지를 정의합니다.
이렇게만 입력해도 타이핑 효과는 완성되었습니다.
박스는 이미 몇 번 만들어 봤을 테니 간단하게 넘어가겠습니다.
const layerName = thisLayer.name;
const renderTextLayer = thisComp.layer("RenderText");
const wb = thisComp.layer(layerName+"_Control").effect("Width_Blank")("Slider").valueAtTime(0);
const wh = thisComp.layer(layerName+"_Control").effect("Heigth_Blank")("Slider").valueAtTime(0);
const boxWidth = renderTextLayer.text.sourceText.length === 0 ? 0 : renderTextLayer.sourceRectAtTime().width + wb;
const boxHeigth = renderTextLayer.text.sourceText.length === 0 ? 0 : renderTextLayer.sourceRectAtTime().height + wh;
[boxWidth, boxHeigth]
박스 사이즈는 RenderText의 사이즈에서 Box_Control에 있는 Width_Blank와 Heigth_Blank의 슬라이더 값으로 여백을 조절합니다.
앵커와 포지션은 여러 번 이야기 했으니 넘어가겠습니다. 이렇게 타이핑 효과가 완성됩니다.
타이핑 자막 템플릿 첨부합니다. 필요하신 분은 다운 받아서 사용하세요. 속성은 다음과 같이 빼놨습니다.

한글 타이핑 자막 + 포인트 텍스트에만 강조 박스 만들기
이번에는 한글 타이핑 자막에 포인트 텍스트를 추가하려고 합니다.
먼저 포인트 텍스트에만 강조 박스 만들기에서 작업했던 컴포지션을 복사해서 그대로 사용합니다. 여기서 텍스트가 출력되는 부분에 타이핑 효과를 적용하면 되겠죠?
한글 타이핑 자막에서는 그동안 써왔던 posterizeTime()은 사용하지 않습니다. 왜냐하면 실시간을 타이핑이 되면서 텍스트 레이어의 가로나 세로 값이 바뀌어야하기 때문에 posterizeTime()을 적용해버리면 타이핑 효과가 나타나지 않습니다.

레이어는 동일합니다. RenderText_Control과 Box_Control 레이어에 적용한 효과는 다음과 같습니다.


앞에서 구현한 타이핑효과에 있던 StartTime 속성을 제외했습니다. 코드 안에서 자동으로 계산해서 앞 레이어의 타이핑이 끝나면 바로 이어지도록 구현했습니다.
//RenderText_Left의 소스텍스트
totalText = thisComp.layer("Text").text.sourceText;
pointText = thisComp.layer("PointText").text.sourceText.valueAtTime(0);
renderText = (pointText.length === 0 || totalText.indexOf(pointText) === -1) ? totalText.valueAtTime(0) : totalText.valueAtTime(0).split(pointText)[0].replace(/\s+$/, "");
let cCho = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"],
cJung = ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"],
cJong = ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"];
let toStringFromCharCode = (cho, jung, jong) =>
String.fromCharCode(44032 + cho * 588 + jung * 28 + (jong || 0));
let seperateChar = (char) => {
let cCode = char.charCodeAt() - 0xac00;
let jong = cCode % 28;
let jung = ((cCode - jong) / 28) % 21;
let cho = ((cCode - jong) / 28 - jung) / 21;
return []
.concat(cCho[cho])
.concat(toStringFromCharCode(cho, jung))
.concat(cJong[jong] !== "" ? toStringFromCharCode(cho, jung, jong) : []);
};
let toKorChars = (char) => {
let cCode = char.charCodeAt();
return [].concat(
(!cCode && []) ||
(cCode === 32 && " ") ||
((cCode < 0xac00 || cCode > 0xd7a3) && char) ||
seperateChar(char)
);
};
let typing = (str) =>
str
.split("")
.map((char) => toKorChars(char))
.reduce(
(pre, cur) =>
pre.concat(
cur.map((v) => (pre.length !== 0 ? pre[pre.length - 1] : "") + v)
),
[""]
);
let p = thisComp.layer("RenderText_Control").effect("Speed")("Slider");
let strList = typing(renderText);
if (time > 0) {
let adjustedTime = time;
let strListIndex = Math.round(adjustedTime*p);
thisComp.layer("Text").text.sourceText.getStyleAt(0,0).setText([strList[strList.length > strListIndex ? strListIndex : strList.length-1]]);
} else {
[]
}
//RenderText_Center의 소스텍스트
let cCho = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"],
cJung = ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"],
cJong = ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"];
let toStringFromCharCode = (cho, jung, jong) =>
String.fromCharCode(44032 + cho * 588 + jung * 28 + (jong || 0));
let seperateChar = (char) => {
let cCode = char.charCodeAt() - 0xac00;
let jong = cCode % 28;
let jung = ((cCode - jong) / 28) % 21;
let cho = ((cCode - jong) / 28 - jung) / 21;
return []
.concat(cCho[cho])
.concat(toStringFromCharCode(cho, jung))
.concat(cJong[jong] !== "" ? toStringFromCharCode(cho, jung, jong) : []);
};
let toKorChars = (char) => {
let cCode = char.charCodeAt();
return [].concat(
(!cCode && []) ||
(cCode === 32 && " ") ||
((cCode < 0xac00 || cCode > 0xd7a3) && char) ||
seperateChar(char)
);
};
let typing = (str) =>
str
.split("")
.map((char) => toKorChars(char))
.reduce(
(pre, cur) =>
pre.concat(
cur.map((v) => (pre.length !== 0 ? pre[pre.length - 1] : "") + v)
),
[""]
);
const countCharComponents = str => {
if (typeof str !== "string" || !str) return 0;
return str.split("").reduce((sum, ch) => {
return sum + toKorChars(ch).length;
}, 0);
};
const totalText = thisComp.layer("Text").text.sourceText.valueAtTime(0);
const pointText = thisComp.layer("PointText").text.sourceText.valueAtTime(0);
let firstRenderText, beforeRenderText, renderText;
if (!pointText || totalText.indexOf(pointText) === -1) {
beforRrenderText = totalText;
renderText = "";
} else {
beforeRenderText = totalText.split(pointText)[0].replace(/\s+$/, "");
renderText = pointText.replace(/\s+$/, "");
}
const p = thisComp.layer("RenderText_Control").effect("Speed")("Slider").valueAtTime(0);
const startTime = (beforeRenderText) ? countCharComponents(beforeRenderText)/p : 0;
let strList = typing(renderText);
if (time > startTime) {
const adjustedTime = time - startTime;
const strListIndex = Math.round(adjustedTime*p);
thisComp.layer("PointText").text.sourceText.getStyleAt(0,0).setText([strList[strList.length > strListIndex ? strListIndex : strList.length-1]]);
} else {
[]
}
//RenderText_Rigth의 소스텍스트
let cCho = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"],
cJung = ["ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ", "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"],
cJong = ["", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"];
let toStringFromCharCode = (cho, jung, jong) =>
String.fromCharCode(44032 + cho * 588 + jung * 28 + (jong || 0));
let seperateChar = (char) => {
let cCode = char.charCodeAt() - 0xac00;
let jong = cCode % 28;
let jung = ((cCode - jong) / 28) % 21;
let cho = ((cCode - jong) / 28 - jung) / 21;
return []
.concat(cCho[cho])
.concat(toStringFromCharCode(cho, jung))
.concat(cJong[jong] !== "" ? toStringFromCharCode(cho, jung, jong) : []);
};
let toKorChars = (char) => {
let cCode = char.charCodeAt();
return [].concat(
(!cCode && []) ||
(cCode === 32 && " ") ||
((cCode < 0xac00 || cCode > 0xd7a3) && char) ||
seperateChar(char)
);
};
let typing = (str) =>
str
.split("")
.map((char) => toKorChars(char))
.reduce(
(pre, cur) =>
pre.concat(
cur.map((v) => (pre.length !== 0 ? pre[pre.length - 1] : "") + v)
),
[""]
);
const countCharComponents = str => {
if (typeof str !== "string" || !str) return 0;
return str.split("").reduce((sum, ch) => {
return sum + toKorChars(ch).length;
}, 0);
};
const totalText = thisComp.layer("Text").text.sourceText.valueAtTime(0);
const pointText = thisComp.layer("PointText").text.sourceText.valueAtTime(0);
let firstRenderText, beforeRenderText, renderText;
if (!pointText || totalText.indexOf(pointText) === -1) {
firstRenderText = totalText;
beforRrenderText = "";
renderText = "";
} else {
firstRenderText = totalText.split(pointText)[0].replace(/\s+$/, "");
beforeRenderText = pointText.replace(/\s+$/, "");
renderText = totalText.split(pointText)[1];
}
const p = thisComp.layer("RenderText_Control").effect("Speed")("Slider").valueAtTime(0);
const startTime = (firstRenderText && beforeRenderText) ? (countCharComponents(firstRenderText)+countCharComponents(beforeRenderText))/p : 0;
let strList = typing(renderText);
if (time > startTime) {
const adjustedTime = time - startTime;
const strListIndex = Math.round(adjustedTime*p);
thisComp.layer("Text").text.sourceText.getStyleAt(0,0).setText([strList[strList.length > strListIndex ? strListIndex : strList.length-1]]);
} else {
[]
}
RenderText_Left와 Center와 Right는 코드가 살짝 다릅니다. 타이핑이 시작되는 타임을 계산하려다보니 코드가 조금씩 추가되었습니다. 그냥 차례로 복사해서 붙여넣어 주세요.
자바스크립트와 달리 익스프레션은 블록별 코딩이기 때문에 다른 레이어에서 만든 함수를 재사용할 수 없습니다. 그래서 한글 타이핑을 구현하는 함수와 한글 초성, 중성, 종성의 개수를 세는 함수가 각각의 레이어에 모두 들어가게 되었습니다.
//RenderText 레이어들의 앵커포인트
a = thisLayer.sourceRectAtTime();
x = a.left;
y = a.top + a.height;
[x, y]
이번에는 앵커 포인트를 좌측 하단으로 고정했습니다. 타이핑 자체가 왼쪽에서 오른쪽으로 작성될 것이기 때문에 이렇게 고정했습니다.
텍스트 레이어의 포지션 계산
이제 RenderText 레이어의 포지션을 계산하겠습니다. 앵커포인트가 바뀌었으므로 포지션 계산도 조금 수정되었습니다.
//RenderText_Left의 Position
x = thisComp.width*0.1;
y = transform.position[1];
[x, y]
//RenderText_Center의 Position
const defaultPositionTime = thisComp.layer("RenderText_Control").effect("Default_Position_Time")("Slider").valueAtTime(0);
totalText = thisComp.layer("Text").text.sourceText;
leftTextLayer = thisComp.layer("RenderText_Left");
leftText = leftTextLayer.text.sourceText;
text_blank = thisComp.layer("RenderText_Control").effect("Text_blank")("Slider").valueAtTime(0);
default_Text_blank = thisComp.layer("RenderText_Control").effect("Default_Text_blank")("Slider").valueAtTime(0);
default_X_Position = leftTextLayer.transform.position[0] + leftTextLayer.sourceRectAtTime().width;
x = leftText.length !== totalText.indexOf(thisLayer.text.sourceText.valueAtTime(defaultPositionTime)) || thisLayer.text.sourceText.valueAtTime(defaultPositionTime).length !== thisLayer.text.sourceText.valueAtTime(defaultPositionTime).replace(/^\s+/, "").length ? default_X_Position + text_blank : default_X_Position + default_Text_blank;
y = leftTextLayer.transform.position[1];
[x, y]
//RenderText_Right의 Position
const defaultPositionTime = thisComp.layer("RenderText_Control").effect("Default_Position_Time")("Slider").valueAtTime(0);
totalText = thisComp.layer("Text").text.sourceText;
leftTextLayer = thisComp.layer("RenderText_Center");
leftText = leftTextLayer.text.sourceText;
text_blank = thisComp.layer("RenderText_Control").effect("Text_blank")("Slider").valueAtTime(0);
default_Text_blank = thisComp.layer("RenderText_Control").effect("Default_Text_blank")("Slider").valueAtTime(0);
default_X_Position = leftTextLayer.transform.position[0] + leftTextLayer.sourceRectAtTime().width;
x = leftText.length !== totalText.indexOf(thisLayer.text.sourceText.valueAtTime(defaultPositionTime)) - totalText.indexOf(leftText) || thisLayer.text.sourceText.valueAtTime(defaultPositionTime).length !== thisLayer.text.sourceText.valueAtTime(defaultPositionTime).replace(/^\s+/, "").length ? default_X_Position + text_blank : default_X_Position + default_Text_blank;
y = thisComp.layer("RenderText_Left").transform.position[1];
[x, y]
타이핑이 되는 텍스트의 특성상 글자가 타이핑되면서 포지션이 살짝 틀어지는 경우가 있습니다. 예를 들어, ㅎ, 하, 한 이렇게 순서대로 타이핑이 될 때 ㅎ과 한은 크기가 다를 수밖에 없죠. 그리고 이 부분은 폰트의 특성도 탈 수 밖에 없습니다.
그래서 포지션에서 사용하는 값들을 텍스트가 이미 다 출력된 시간의 값으로 계산해야 했습니다. 그래서 defaultPositionTime 변수를 만들어서 사용했습니다.
//PointText 레이어의 소스텍스트
const textcolor = thisComp.layer("RenderText_Control").effect("Point_Color")("Color");
text.sourceText.style.setFillColor(textcolor);
//Text 레이어의 소스텍스트
const textcolor = thisComp.layer("RenderText_Control").effect("Text_Color")("Color");
text.sourceText.style.setFillColor(textcolor);
모션 그래픽 템플릿으로 텍스트 컬러를 지정할 수 있도록 하기 위해 PointText와 Text 레이어에도 익스프레션 코드를 적용했습니다. RenderText 레이어는 이 두 레이어의 스타일을 그대로 불러옵니다.
즉, 디자인 작업을 할 때 이 두 레이어로 디자인을 합니다.
Box 만들기
타이핑 자막에서 박스는 포인트 텍스트를 덮는 형태로 만들었습니다.
//Box - Size
const layerName = thisLayer.name;
const renderTextLayer = thisComp.layer("RenderText_Center");
const wb = thisComp.layer(layerName+"_Control").effect("Width_Blank")("Slider").valueAtTime(0);
const hb = thisComp.layer(layerName+"_Control").effect("Heigth_Blank")("Slider").valueAtTime(0);
const boxWidth = renderTextLayer.text.sourceText.length === 0 ? 0 : renderTextLayer.sourceRectAtTime().width + wb;
const boxHeight = renderTextLayer.text.sourceText.length === 0 ? 0 : renderTextLayer.sourceRectAtTime().height + hb;
[boxWidth, boxHeight]
박스 컬러는 포인트 텍스트의 컬러와 동일한 값으로 지정했습니다.
//Box - Fill - Color
posterizeTime(0);
thisComp.layer("RenderText_Control").effect("Point_Color")("Color");
박스의 앵커 포인트는 조금 계산을 해야합니다.
//Box - Anchor Point
a = thisLayer.sourceRectAtTime();
p = thisComp.layer("Box_Control").effect("Width_Blank")("Slider").valueAtTime(0)/2;
q = thisComp.layer("Box_Control").effect("Heigth_Blank")("Slider").valueAtTime(0)/2;
x = a.left + p;
y = a.top + a.height - q;
[x, y]
박스 앵커 포인트를 좌측 하단으로 정의하면 박스와 포인트 텍스트가 정확하게 가운데 정렬이 되지 않기 때문에 박스 여백 부분을 더해주고 빼줘야 합니다.
마지막으로 앵커 포인트에서 계산했으므로 박스 포지션은 포인트 텍스트인 RenderText_Center의 포지션 값을 그대로 불러오면 됩니다.
//Box - Position
thisComp.layer("RenderText_Center").transform.position;
이제 완성되었습니다.
지금 상태로도 충분히 사용할 수 있는 성능이 나오는 것 같습니다.

속성들을 정리해서 모션 그래픽 템플릿으로 저장했습니다. 필요하신 분은 사용해보세요.
여기서 한 가지만 추가로 개선한다면 타이핑이 완료되는 시간을 계산해서 타이핑이 완료되는 이후에는 posterizeTime()를 적용하면 성능이 더 좋아질 수 있을 것으로 생각됩니다.
그럼 이만~
P.S 포스팅을 하면서 템플릿들을 하나씩 정리하며 업로드 하는 사이트를 만들고 있습니다. 다운 받으시는 분들이 늘어나고 반응이 괜찮으면 회원 가입을 받고 서로의 자료를 공유하는 식으로 발전시키면 어떨까 생각됩니다. 편하게 구경해보시고 템플릿 다운받아 가세요.
답글 남기기