[익스프레션] 핵심 단어 반응형 자막 만들기

짧은 단어에 글자마다 박스가 들어가는 자막 만들기

After Effect Blender expression Premiere 모션 그래픽 템플릿 애프터이펙트 익스프레션 프리미어

지금까지 모션 그래픽 템플릿으로 자막의 기본형들을 만들어봤습니다. 이번 시간은 기본형 자막의 마지막입니다. 여기까지 만드셨다면 디자인을 더해서 다양한 느낌의 자막들을 만들고 적용하실 수 있습니다. 영상을 작업하다보면 인물이 말하는 단어만 포인트로 주는 자막들이 많이 쓰입니다. 특히 예능에서도 많이 쓰이는 자막 형태입니다.

글자 분리하기

먼저 레이어를 만들어줍니다. 이번에는 포인트 텍스트가 필요 없고, 렌더 텍스트와 박스 콘트롤 레이어도 하나면 충분합니다. 단, 박스와 렌더 텍스트는 추가해 나갈 것이므로 넘버링 합니다.

RenderText_01에는 Text 레이어에서 텍스트를 받아 한글자씩 쪼개고 첫 글자만 나타나도록 하겠습니다. 몇 번 썼었죠? split을 사용하겠습니다.
split의 기준 텍스트를 “”로 하게 되면 한 글자씩 나눠서 배열로 출력해줍니다.

thisComp.layer("Text").text.sourceText.valueAtTime(0).split("");

이렇게 띄어쓰기 공백도 한 글자로 취급해서 배열로 만들어주게 됩니다. 이제 첫 번째 원소만 나타내면 되니까 배열의 첫 원소 [0]을 출력해주겠습니다.

thisComp.layer("Text").text.sourceText.valueAtTime(0).split("")[0];

인덱스 값을 바꿔가며 확인해보세요. 원하는 글자가 나타날 것입니다.

자 그럼 매우 간단하게 모든 글자를 나타낼 수 있겠죠? 레이어 이름에서 숫자만 가져와서 숫자를 수(Number)로 바꾸고 -1한 수가 배열의 인덱스로 들어가면 됩니다.

const layerNum = thisLayer.name.replace(/\D/g,"");

//parseInt는 문자로 표시된 숫자를 정수로 바꿔줍니다.
const indexNum = parseInt(layerNum)-1;

thisComp.layer("Text").text.sourceText.valueAtTime(0).split("")[indexNum];

RenderText_01을 ctrl+D로 복제해보세요 그럼 자동으로 글자들이 나타날 겁니다.

Box 맞추기

다시 렌더 텍스트들 하나만 남기고 지워주세요.
박스와 텍스트의 앵커 포인트는 모두 가운데로 고정시켜주고 박스 포지션은 텍스트를 따라가게 맞춰주세요.

//박스 사이즈 텍스트 사이즈에서 여백은 슬라이더 콘트롤로 조절

const layerNum = thisLayer.name.replace(/\D/g,"");
const layerName = thisLayer.name.replace(/\d/g,"");

const renderTextLayer = thisComp.layer("RenderText_"+layerNum).sourceRectAtTime(0);

const wb = renderTextLayer === 0 ? 0 : thisComp.layer(layerName+"Control_"+layerNum).effect("Width_Blank")("Slider").valueAtTime(0);
const hb = renderTextLayer === 0 ? 0 : thisComp.layer(layerName+"Control_"+layerNum).effect("Heigth_Blank")("Slider").valueAtTime(0);

const boxWidth = renderTextLayer.width + wb;
const boxHeight = renderTextLayer.height + hb;

[boxWidth, boxHeight]
//앵커 포인트 박스, 텍스트 둘 다

a = thisLayer.sourceRectAtTime(0);

x = a.left + a.width/2;
y = a.top + a.height/2;

[x, y]
//박스 포지션 같은 숫자의 렌더 텍스트와 연결

const layerNum = thisLayer.name.replace(/\D/g,"");

thisComp.layer("RenderText_"+layerNum).transform.position.valueAtTime(0);

이렇게 하면 박스와 텍스트는 똑같이 움직이고, 박스의 중앙에 텍스트가 들어가게 됩니다.

두 번째 텍스트 위치 값 정하기

이제 박스와 텍스트 레이어를 하나씩 복제해줍니다. (ctrl+D)

박스들은 각자 자신과 동일한 숫자의 텍스트를 따라다니는데 텍스트가 같은 위치에 나타나서 겹쳐보입니다. 우리는 두 번째 텍스트가 첫 번째 텍스트 옆으로 위지하도록 포지션 값을 맞춰주면 됩니다.

//RenderText_02 - Position

const layerNum = thisLayer.name.replace(/\D/g,"");
//이전 레이어의 숫자 찾기
const beforeLayerNum = parseInt(layerNum)-1;

const boxSize = thisComp.layer("Box_" + layerNum).content("Rectangle 1").content("Rectangle Path 1").size.valueAtTime(0)[0];

const Default_Text_blank = thisComp.layer("RenderText_Control").effect("Default_Text_blank")("Slider").valueAtTime(0);

//beforeLayerNum는 정수입니다. 즉, 02가 아닌 2가 되죠. 그런데 우리가 레이어 이름을 01, 02 이렇게 이름붙여놔서 2를 02로 바꿔줘야 합니다. 이때 padStart를 사용합니다.
//padStart(몇글자로 만들어?, 빈곳에 어떤걸 채워?)는 결과 문자열이 주어진 길이에 도달할 때까지 이 문자열의 시작 부분에 다른 문자열을 채웁니다.
//padStart는 문자열에서 사용할 수 있기 때문에 String(beforeLayerNum)이렇게 숫자를 문자열로 바꿔준 뒤 사용합니다.
//String(beforeLayerNum).padStart(2, '0')는 정수 2를 2글자로 바꿔주고 빈 곳은 0을 채워서 02로 바꿔줍니다.
//사실 padStart를 사용해서 복잡하게 만드는 것보다 레이어 이름을 그냥 RenderText_1, RenderText_2 형태로 바꿔주면 beforeLayerNum만으로 사용할 수 있습니다.
x = thisComp.layer("RenderText_" + String(beforeLayerNum).padStart(2, '0')).transform.position.valueAtTime(0)[0] + boxSize + Default_Text_blank;
y = thisComp.layer("RenderText_" + String(beforeLayerNum).padStart(2, '0')).transform.position.valueAtTime(0)[1];

[x, y]

어때요 조금 복잡한가요? 우리가 레이어 이름에서 넘버링을 01, 02…이렇게 해놨기 때문에 padStart로 조금 복잡하게 코드가 작성되었습니다.

쉽게 하기 위해 레이어 이름의 넘버링을 그냥 1, 2…로 바꿔주겠습니다.

//RenderText_02 - Position 수정

const layerNum = thisLayer.name.replace(/\D/g,"");
const beforeLayerNum = parseInt(layerNum)-1;
const boxSize = thisComp.layer("Box_" + layerNum).content("Rectangle 1").content("Rectangle Path 1").size.valueAtTime(0)[0];

const Default_Text_blank = thisComp.layer("RenderText_Control").effect("Default_Text_blank")("Slider").valueAtTime(0);
const Text_blank = thisComp.layer("RenderText_Control").effect("Text_blank")("Slider");

x = thisComp.layer("RenderText_" + beforeLayerNum).transform.position.valueAtTime(0)[0] + boxSize + Default_Text_blank;
y = thisComp.layer("RenderText_" + beforeLayerNum).transform.position.valueAtTime(0)[1];

[x, y]

레이어 이름을 수정했더니 코드가 조금 간결해졌습니다.

지금 보면 모션에서 모와, 션이 글자 자체가 차이가 큽니다. 따라서 박스도 차이가 나는데요 글자 자체는 차이가 나더라도 박스는 같은 크기로 유지가 되는 것이 보기 좋을 것 같습니다. Box_2를 Box_1과 동일한 사이즈로 맞추도록 하겠습니다.

박스 2는 박스 1로 사이즈를 고정했습니다. 만약 박스 2 레이어를 복제하면 복제하는 모든 박스들의 사이즈는 박스 1로 고정됩니다.

이제는 조금 보기 좋아졌네요. 박스 여밸을 조금 더 조절하면 더 좋아질 것으로 보입니다.

여백을 조절하고 나니 좀 더 보기 좋아졌습니다. 현재 ‘션’이라는 텍스트는 폰트 특성 상 약간 우측으로 쏠려 있는 것 처럼 보입니다. 이것은 폰트 특성이라 그냥 넘어가겠습니다.

실제로는 정확하게 가운데로 맞춰져 있습니다.

박스와 텍스트 02를 복제해서 늘렸습니다. 텍스트가 옆으로 잘 붙어서 나열됩니다. 그런데 문제가 하나 보이네요. 띄어쓰기, 즉 텍스트가 공백일 때 박스 사이즈가 그대로 유지됩니다. 그래서 띄어쓰기 처럼 보이지 않네요. 이 부분을 수정하겠습니다. 우선 조금 전 추가했던 레이어들은 모두 다시 삭제해주세요.

//Box_2 Size

const layerNum = thisLayer.name.replace(/\D/g,"");
const DefaultTextLayer = thisComp.layer("RenderText_" + layerNum);

DefaultTextLayer.sourceRectAtTime(0).width === 0 ? [0,0] : thisComp.layer("Box_1").content("Rectangle 1").content("Rectangle Path 1").size.valueAtTime(0);

박스 2의 사이즈를 수정합니다. 만약에 텍스트 2가 없으면(가로 길이가 0이면) 박스도 [0,0]으로 수정하였습니다. 그 외의 경우에는 박스 1의 사이즈를 그대로 가져오게 하였습니다.

다시 박스 2와 텍스트 2를 복제해서 확인해봅니다.

의도했던 대로 잘 동작합니다.

이제 띄어쓰기 부분의 여백을 조금 더 디테일하게 조절 할 수 있도록 Text_Blank로 변수를 하나 더 추가하겠습니다.

//RenderText_02 - Position 수정

const layerNum = thisLayer.name.replace(/\D/g,"");
const beforeLayerNum = parseInt(layerNum)-1;
const boxSize = thisComp.layer("Box_" + layerNum).content("Rectangle 1").content("Rectangle Path 1").size.valueAtTime(0)[0];

const Default_Text_blank = thisComp.layer("RenderText_Control").effect("Default_Text_blank")("Slider").valueAtTime(0);
const Text_blank = thisComp.layer("RenderText_Control").effect("Text_blank")("Slider").valueAtTime(0);

const DefaultXPositon = thisComp.layer("RenderText_" + beforeLayerNum).transform.position.valueAtTime(0)[0] + boxSize + Default_Text_blank

x = thisLayer.sourceRectAtTime(0).width === 0 ? DefaultXPositon + Text_blank : DefaultXPositon;
y = thisComp.layer("RenderText_" + beforeLayerNum).transform.position.valueAtTime(0)[1];

[x, y]

이렇게 해서 띄어쓰기가 있는 부분은 특별히 여백을 더 줄 수 있도록 수정하였습니다.

첫 번째 텍스트 위치 값 정하기

이제 마지막으로 첫 번째 텍스트의 위치를 결정해야 합니다. 이 전체 텍스트가 화면의 가운데 나오도록 할 것이기 때문에 첫 번째 글자의 위치를 전체 박스 사이즈와 여백을 계산해서 컨트롤 하겠습니다.

모든 박스는 Box_1의 크기를 따릅니다. 그리고 여백은 Default_Text_blank이 전체 텍스트 레이어 개수 -1개, 그리고 Text_blank은 전체 텍스트에서 띄어쓰기의 개수입니다.

//RenderText_01 - Position

const boxSize = thisComp.layer("Box_1").content("Rectangle 1").content("Rectangle Path 1").size.valueAtTime(0)[0];
const Text_Blank = thisComp.layer("RenderText_Control").effect("Text_blank")("Slider").valueAtTime(0);
const Default_Text_Blank = thisComp.layer("RenderText_Control").effect("Default_Text_blank")("Slider").valueAtTime(0);

const totalTextLength = thisComp.layer("Text").text.sourceText.valueAtTime(0).length;
//.replace(/ /g, "") : 띄어쓰기를 제거
const countTextLength = thisComp.layer("Text").text.sourceText.valueAtTime(0).replace(/ /g, "").length;
const spaceCount = totalTextLength - countTextLength;

const totalWidth = boxSize*countTextLength + Default_Text_Blank*(totalTextLength-1) + Text_Blank*spaceCount;

x = thisComp.width/2 - totalWidth/2 + boxSize/2;

//y값은 자기 자신의 y값을 참조(렌더텍스_1의 y값은 이동가능)
y = transform.position.valueAtTime(0)[1];

[x, y]

이렇게 정확하게 가운데 위치하게 됩니다.

자막이 나올 높이를 적당히 조절해주고 레이어 순서를 정리해 줬습니다.

오류 해결하기

지금 만든 템플릿은 오류가 하나 있습니다. 바로 레이어 개수보다 입력한 텍스트 글자 수가 더 많은 경우 위치가 틀어지게 됩니다.

이렇게 되는 것이죠.

그리고 모션그래픽 템플릿으로 출력할 때, 레이어의 개수를 정해야합니다. 즉, 텍스트 레이어가 10개라면 글자는 10자 이하로 적어야만 하는 거겠죠. 물론 이렇게 마무리하고 사용할 때 주의해서 사용해도 무방합니다. 그리고 포인트 단어를 표현하기 위한 템플릿이므로 텍스트 레이어가 너무 많아질 필요도 없습니다.

Text 레이어의 Source Text 익스프레션 블록을 열고 이렇게 입력해보세요.

//numLayers로 컴포지션 안에 있는 레이어의 개수를 셀 수 있습니다.
//이 컴포지션 안에는 조정레이어가 2개, 총 텍스트를 입력하는 레이어 1개, 그리고 박스레이어와 렌더 텍스트 레이어가 같은 개수로 있습니다. 현재는 13쌍이 있죠.
//이렇게 레이어 개수 중에서 렌더 텍스트 레이어가 몇 개인지 셀 수 있습니다.
const textLayerNum = (thisComp.numLayers-3)/2;

//slice(indexStart, indexEnd)는 문자열의 특정 구간을 출력해줍니다.
//slice(4, 8)이라면 index가 4인 문자부터 7인 문자까지 출력해줍니다.
//즉, slice(0, textLayerNum)는 첫 문자부터 index가 textLayerNum-1인 문자까지 출력해줍니다. -> 0~12까지 13개의 문자를 출력해주는 것이죠.
text.sourceText.valueAtTime(0).slice(0, textLayerNum);

이러면 만약 작업을 할 때 입력한 텍스트가 렌더 텍스트 레이어의 개수보다 많아지더라도 Text레이어에서 자동으로 뒤에 입력한 텍스트를 잘라 버려줍니다.

이렇게 입력한 텍스트를 자동으로 13자로 줄여줍니다.
이렇게 하면 자막의 포지션이 틀어지는 오류도 수정할 수 있습니다.

기능 추가해보기

이제 부턴 디자인의 영역이 됩니다. 포인트 단어이긴 하지만 모든 텍스트가 동일한 색과 동일한 박스라면 약간 심심할 수 있습니다.

지금부터는 구체적인 디자인을 하기보다는 원하는 부분의 색을 바꿔보는 작업을 하겠습니다.(물론 템플릿에서 수정할 수 있도록 해야겠죠?)

지금 만드는 자막은 글자 수가 많지 않습니다. 저는 최대 15글자로 생각하고 있습니다. 일단 레이어는 RenderText를 15까지 복제하고 시작하겠습니다. 그리고 Text 레이어를 하나 더 추가하겠습니다.

레이어가 하나 추가되었으니 수정해야할 코드가 하나 있죠?

//Text - Source Text 수정 (레이어가 하나 추가되었으니 -4로 수정합니다.)
const textLayerNum = (thisComp.numLayers-4)/2;
text.sourceText.valueAtTime(0).slice(0, textLayerNum);

이것도 함수를 만들어서 자동화할 수는 있겠지만 굳이 그렇게 까지 할 만한 작업은 아니고, 오히려 많은 계산을 시킬수록 무거워질 수 있으니 간단한 수정은 그냥 하는 것이 더 좋습니다.

이제 PointIndexList 레이어의 Source Text 익스프레션 블록을 열고 다음을 복사해서 붙여 넣으세요.

const PointIndexList = thisLayer.text.sourceText
  .split(',')
  .reduce((acc, part) => {
    const [start, end] = part.split('-').map(Number);
    return end == null
      ? [...acc, start]
      : [
          ...acc,
          ...[...Array(end - start + 1).keys()].map(i => start + i)
        ];
  }, []);

PointIndexList;

코드가 조금 어려워 보이지만 설명하려면 길어지니 이 부분은 설명 없이 넘어가겠습니다.
결론적으로 이 코드가 하는 역할은 PointIndexList 레이어에 2-4,8,10이라는 값을 넣었을 때, [2, 3, 4, 8, 10]으로 바꿔줍니다. 2-4 부분을 체크해서 2부터 4까지 값으로 각각 바꿔줍니다.

이제 각각의 텍스트 레이어에서 숫자만 분리해서 수로 만들고, PointIndexList에 포함되는지 체크한 뒤 색을 바꿔주겠습니다.

//RenderText의 모든 레이어의 Source Text에 들어갈 익스프레션 코드

const layerNum = thisLayer.name.replace(/\D/g,"");
const indexNum = parseInt(layerNum)-1;

//RenderText_Control에서 color control로 포인트 텍스트 연결
const pointColor = thisComp.layer("RenderText_Control").effect("PointColor")("Color").valueAtTime(0);

const pointTextArray = thisComp.layer("PointIndexList").text.sourceText.split(",");
const checkPointText = pointTextArray.indexOf(layerNum);

const renderTextStyle = thisComp.layer("Text").text.sourceText.getStyleAt(0,0);
const renderText = thisComp.layer("Text").text.sourceText.valueAtTime(0).split("")[indexNum];

//PointIndexList에 이 레이어의 번호가 없으면 Text 레이어의 스타일 적용, 있으면 색만 pointColor 로 변경 
checkPointText === -1 ? renderTextStyle.setText(renderText) : renderTextStyle.setFillColor(pointColor).setText(renderText);

이제 익스프레션 코드만 복사헤서 모든 텍스트 레이어에 ctrl+V 해주세요. 모든 텍스트 레이어는 같은 익스프레션 코드로 적용합니다.

똑같은 작업을 박스 Fill Color에도 적용합니다.

const layerNum = thisLayer.name.replace(/\D/g,"");
const indexNum = parseInt(layerNum)-1;

const pointTextArray = thisComp.layer("PointIndexList").text.sourceText.split(",");
const checkPointText = pointTextArray.indexOf(layerNum);

//pointColor는 Box_Control 레이어의 Color Control로 연결
const pointColor = thisComp.layer("Box_Control").effect("BoxPointColor")("Color").valueAtTime(0);

const renderTextStyle = thisComp.layer("Text").text.sourceText.getStyleAt(0,0);
const renderText = thisComp.layer("Text").text.sourceText.valueAtTime(0).split("")[indexNum];

//checkPointText 값이 -1이면 현재 박스 컬러, 아니면 포인트컬러로 출력
checkPointText === -1 ? content("Rectangle 1").content("Fill 1").color : pointColor;

도형 레이어에서는 익스프레션 코드만 복사해서 붙여넣을 때 같은 fill에 코드가 붙여넣어지지 않습니다. Group 레이어가 생기면서 Fill 값이 새로 생기게 되는데요, 그 이유는 도형 레이어는 레이어 안에 여러 도형을 만들어 넣고 그룹화 시킬 수 있기 때문입니다.
그냥 1, 2번 박스만 코드 수정하고, 나머지 박스들은 전부 지운 뒤 2번 박스를 다시 복제하는 편이 빠릅니다.

이렇게 우리가 지정한 문자들만 색이 바뀌었습니다.

이걸 바탕으로 디자인 작업을 하시면 됩니다.

레이어가 많이 쌓인 만큼 아무래도 무겂습니다. 따라서 지난 시간에 썼던 posterizeTime()까지 작업해 마무리 하고 모션 작업까지 하면 이번 자막도 완성할 수 있습니다.

지난 시간에는 posterizeTime()코드를 실제 값을 출력할 때 조건문으로 작성 했었습니다.

if (time > 0) {
	posterizeTime(0);
	totalText.getStyleAt(0,0).setText(renderText);
} else {
	totalText.getStyleAt(0,0).setText(renderText);
}

이런 형태였죠.

하지만 생각해보면 posterizeTime()이 실행된다고 해서 그 다음 줄에 있는 코드가 동작을 하지 않는 것은 아닙니다. 그리고 자바스크립트는 “동기적” 코드(함수 정의나 요청 없이 바로 실행되는 일반 문장)는 원칙적으로 위에서 아래로 순차 실행됩니다.

즉, posterizeTime()가 실행되고 나서도 그 아래 있는 코드들이 실행된다는 의미죠. 이때 posterizeTime()은 그저 프레임 레이트를 줄여주는 역할만 합니다. 화면에 뿌려지는 값들에 영향을 미치지 않는다는 것이죠.

또, 지난 시간의 코드를 보면 조건문으로 작성한 코드 자체가 의미가 없습니다. 지난 시간에는 그냥 설명을 하기 위한 용도로 활용하다 보니 조건 문 형태로 설명하는 것이 이해하기 편할 것 같아서 조건문으로 사용하였습니다.

저 조건문을 보면 시간이 0초가 넘는 순간부터 프레임을 0으로 줄이고, 결과값을 출력하는 형태입니다. 따라서 매 프레임마다 체크해서 결과값을 출력하기 한 전 단계의 계산을 하고 있다는 의미입니다.

애프터 이펙트에서 타임라인은 시작하는 순간 0초가 넘어갑니다. 따라서 저 코드는 큰 의미가 없는 코드라는 것이죠.

그렇다면 어떻게 코드를 작성해야할까요?

간단합니다. 우리는 모든 프레임에 걸쳐서 posterizeTime(0)을 적용할 것이므로 코드 최 상단에 posterizeTime(0)을 넣어주면 최고의 효과를 기대할 수 있습니다.(솔직히 어디서도 알려주지 않는 방법 아닐까 생각이 드네요.)

//RenderText 레이어 Source Text

posterizeTime(0);
const layerNum = thisLayer.name.replace(/\D/g,"");
const indexNum = parseInt(layerNum)-1;

const pointTextArray = thisComp.layer("PointIndexList").text.sourceText.valueAtTime(0).split(",");
const checkPointText = pointTextArray.indexOf(layerNum);

const pointColor = thisComp.layer("RenderText_Control").effect("PointColor")("Color").valueAtTime(0);

const renderTextStyle = thisComp.layer("Text").text.sourceText.getStyleAt(0,0);
const renderText = thisComp.layer("Text").text.sourceText.valueAtTime(0).split("")[indexNum];

checkPointText === -1 ? renderTextStyle.setText(renderText) : renderTextStyle.setFillColor(pointColor).setText(renderText);

이렇게 모든 익스프레션 블록의 최상단에 posterizeTime(0)를 넣어주세요. 이번 시간에 만든 템플릿은 모션을 준 레이어도 없고, 프레임에 따라 값이 바뀌는 요소도 없습니다.
즉, 모두 시작하는 순간 결정되는 상수라는 것이죠. 고민할 필요 없이 코드 최상단으로 posterizeTime(0)를 넣어주세요.

어떤가요? 렌더되는 모든 레이어에 모션 블러가 들어간 상태인데도 성능이 굉장히 좋아지지 않았나요?

여기까지 해서 이제 자막의 기본 형태는 모두 만들었습니다. 이제는 디자인을 하고, 모션을 주면 원하는 자막은 모두 만드실 수 있습니다.

이번 시간은 여기까지 하겠습니다.

그럼 이만~

P.S 포스팅을 하면서 템플릿들을 하나씩 정리하며 업로드 하는 사이트를 만들고 있습니다. 다운 받으시는 분들이 늘어나고 반응이 괜찮으면 회원 가입을 받고 서로의 자료를 공유하는 식으로 발전시키면 어떨까 생각됩니다. 편하게 구경해보시고 템플릿 다운받아 가세요.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다