“批量生产”、“快速裂变”和“去重”是制作营销短视频的关键,基于有限数量的基础素材大规模生成指定数量的新视频,是营销短视频创作的常见思路。本篇主要介绍一些经验方法,助您更快更高效地生产优质短视频。
1. 概述
1.1 背景
进入5G时代,越来越多的商家选择短视频平台做营销推广,将广告制作成短视频投放在多个短视频营销号。
随着短视频内容的发展,视频生产的质量和效率已经越来越重要,本文从理论入手,介绍短视频生产中的方法和经验,本方案的示例基于智能媒体服务IMS,并附有示例代码,帮助大家更快上手短视频的批量合成。
1.2 目标读者
有短视频批量制作的商家,或视频批量生产工具的开发者。
2. 方案介绍
先来看一个示例
生产此视频的素材是一段文字和一批视频,将文字转成人声朗读和字幕,再和视频进行合成
文字:
“人们懂得用五味杂陈形容人生,因为懂得味道是每个人心中固守的情怀。在这个时代,每一个人都经历了太多的苦痛和喜悦,人们总会将苦涩藏在心里,而把幸福变成食物,呈现在四季的餐桌之上”
视频:
这是一个非常常见的营销短视频,当策略敲定好后,通过替换素材、特效、文字,可批量生产出大量视频,从而达到批量生产的目的。
视频生产大概分“设计剧本-素材挑选-视频合成”几个步骤,我们来依次介绍其中的技巧:
1、分镜时长
视频中一个场景镜头我们称之为“分镜”,一个分镜长度不易超过3s,特别是在一个15s的短视频中,过长的分镜会让用户视觉疲劳,一般在电影中一个分镜也在2~3s左右。
如果剪辑中对分镜的时长没有特殊要求,可以对素材随机截取2s来使用,不足2s的取原素材时长。
2、素材卡点
上面的示例中,广告词转换后的人声,每句话对应一个视频素材,视频的开始结束时间正好卡在一句话的开始和结束,整个视频更有节奏感。
短视频虽然短,但一般也会有一个故事主线,可以是一段广告词或一段卡点音乐,一句话的朗读大约在2~3s,也正好贴合一个分镜的时长,人声-字幕-素材时间点完全对应,效果往往更好。
MPS智能生产接口提供了文字转语音、语音识别、音乐节奏检测能力,方便用户根据一个原始素材生产出故事主线。素材卡点的整体流程:
根据文字生成卡点:
根据卡点截取素材,并将截取后的视频素材放到时间线中
补充音乐、特效、转场,并合成成片
3、素材挑选
上面讲到了一个分镜的时长为2~3s,如果素材是由C端用户上传,或者运营采集的,建议加一个强限制,输入的素材时长不能短于2s,这样免去后续很多适配的麻烦。
实际上,2~3s的视频采集并不是难事,无论是拍摄美食、萌宠、还是街边,一个合理的运镜能采集到很好的一段短素材。
以下面这段素材为例,时长足够长,无论从第几秒开始截取,无论截取几秒,都是一个不违和的内容,不用担心对截取会对素材有影响。
4、准备模板
除了广告词和卡点音乐,还有一种剧本是来源于模板,客户会准备一些模板(如:美食类、探店类、萌宠类),合成时把素材填充进模板进行合成。
和前面提到的相同,合成时根据模板的要求,对素材进行截取。比如一个模板分别需要一个1s、2s、3s的素材,那就把素材分别进行截取,填充到模板中。
如果模板中需要一个3s的素材,但输入的最长素材是2s怎么办呢?
一般可以准备多套模板,分别适配多种时长的场景,当有素材输入时,根据素材时长挑选到合适的模板进行合成即可。
综上,一个好的生产流程是:
先对素材进行挑选,挑选出足够多的时长合适的素材,根据素材挑选合适的剧本,用剧本来对素材进行截取,然后合成。按照这个流程来制作,逻辑清晰、效果好、代码简洁。
相反的做法是: 用户上传的素材多种多样,业务方根据用户素材做大量复杂的适配,这样投入产出比低。无论是广告词,还是模板,对故事主线做了修改,效果都会大打折扣。
3. 方案实施
下面介绍前面示例短视频的代码示例。
整体流程:
1、根据文字素材,调用MPS智能生产接口,生成人声音频和文字卡点
2、根据文字卡点截取视频素材,生成时间线,调用剪辑接口,合成成片
3、重复2,合成多条视频
3.1 接口说明
提供文字转语音、人声识别、节奏检测等能力
输入Timeline和成片地址,提交剪辑合成作业,返回剪辑作业JobId
3.2 示例代码
/**
Maven引入:
com.aliyun
aliyun-java-sdk-core
4.5.3
com.aliyun
ice20201109
1.2.0
com.aliyun
mts20140618
3.3.33
com.alibaba
fastjson
*/
package com.aliyun.ice.util;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.aliyun.abs.common.util.MD5Util;
import com.aliyun.ice20201109.Client;
import com.aliyun.ice20201109.models.GetMediaProducingJobRequest;
import com.aliyun.ice20201109.models.GetMediaProducingJobResponse;
import com.aliyun.ice20201109.models.SubmitMediaProducingJobRequest;
import com.aliyun.ice20201109.models.SubmitMediaProducingJobResponse;
import com.aliyun.mts20140618.models.QueryIProductionJobRequest;
import com.aliyun.mts20140618.models.QueryIProductionJobResponse;
import com.aliyun.mts20140618.models.SubmitIProductionJobRequest;
import com.aliyun.mts20140618.models.SubmitIProductionJobResponse;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.OSSObject;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.teaopenapi.models.Config;
import com.google.common.io.CharStreams;
import org.apache.commons.io.Charsets;
import org.apache.logging.log4j.util.Strings;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* Create by oushu
* Date 2021/12/7 上午9:28
*/
public class BatchProduceTTSSubtitleRelease {
private String accessKeyId;
private String accessKeySecret;
private OSS ossClient;
private String bucket;
private String regionId;
private com.aliyun.mts20140618.Client mpsClient;
public void initClient() throws Exception {
accessKeyId = "your_ak";
accessKeySecret = "your_sk";
bucket = "your-bucket";
regionId = "cn-shanghai";
ossClient = createOssClient();
mpsClient = createMpsClient();
}
public static void main(String[] args) throws Exception {
BatchProduceTTSSubtitleRelease batchProduceVideo = new BatchProduceTTSSubtitleRelease();
batchProduceVideo.initClient();
batchProduceVideo.batchProduceVideo();
}
public void batchProduceVideo() throws Exception {
// 文字素材
String text = "人们懂得用五味杂陈形容人生,因为懂得味道是每个人心中固守的情怀。在这个时代,每一个人都经历了太多的苦痛和喜悦,人们总会将苦涩藏在心里,而把幸福变成食物,呈现在四季的餐桌之上";
// 视频素材
List videoUrlList = new ArrayList();
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_1.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_2.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_3.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_4.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_5.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_6.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_7.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_8.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_9.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_10.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_11.mp4");
videoUrlList.add("https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your_video_12.mp4");
// 背景音乐
String bgMusic = "https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/iproduction/demo/music/generated_6_good.wav";
// 字幕样式设置
Integer fontSize = 45;
String fontName = "WenQuanYi Zen Hei Mono";
String fontColor = "#FFFFFF";
// 视频尺寸
Integer width = 720;
Integer height = 1280;
// logo
String logoUrl = "https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/your-logo.png";
Integer logoX = 20;
Integer logoY = 20;
String title = "这里是标题";
String subTitle = "这里是副标题";
// 成片数量
int targetCount = 2;
// 每次提交一个任务,业务方根据需要更换不同参数提交多次
produceSingleVideo(text, videoUrlList, title, subTitle, bgMusic, fontSize, fontName, fontColor, logoUrl, logoX, logoY, width, height, targetCount);
}
// 提交单个任务
public void produceSingleVideo(String text, List videoUrls, String title, String subtitle, String bgMusic, int fontSize, String fontName, String fontColor,
String logoUrl, Integer logoX, Integer logoY, int width, int height, int targetCount) throws Exception {
// 提交MPS任务,文字生成语音和字幕
String voice = "zhichu";
String jobParams = "{\"voice\":\"" + voice + "\",\"format\":\"mp3\",\"sample_rate\":16000}";
String textObject = "iproduction/20211214/" + MD5Util.getMd5String(text) + ".txt";
text = text.replaceAll(",", "。"); // AsyncTextToSpeech任务用句号进行断句
putObjectContent(textObject, text);
String input = "oss://" + bucket + ".oss-" + regionId + ".aliyuncs.com/" + textObject;
System.out.println("input : " + input);
SubmitIProductionJobRequest submitIProductionJobRequest = new SubmitIProductionJobRequest();
submitIProductionJobRequest.setFunctionName("AsyncTextToSpeech");
submitIProductionJobRequest.setInput(input);
submitIProductionJobRequest.setOutput("oss://" + bucket + ".oss-" + regionId + ".aliyuncs.com/iproduction/20220617/{source}-{sequenceId}.{resultType}");
submitIProductionJobRequest.setJobParams(jobParams);
SubmitIProductionJobResponse submitIProductionJobResponse = mpsClient.submitIProductionJob(submitIProductionJobRequest);
System.out.println("submit mps response : " + JSONObject.toJSONString(submitIProductionJobResponse.body));
// 等待任务完成
String jobId = submitIProductionJobResponse.body.jobId;
String result;
while (true) {
QueryIProductionJobRequest queryIProductionJobRequest = new QueryIProductionJobRequest();
queryIProductionJobRequest.setJobId(jobId);
QueryIProductionJobResponse queryIProductionJobResponse = mpsClient.queryIProductionJob(queryIProductionJobRequest);
System.out.println("job info : " + JSONObject.toJSONString(queryIProductionJobResponse.body));
if ("Success".equals(queryIProductionJobResponse.body.state)) {
result = queryIProductionJobResponse.body.result;
break;
}
Thread.sleep(5000);
}
// 获取生成的音频和字幕
JSONObject resultObj = JSONObject.parseObject(result);
String dataString = resultObj.getString("Data");
System.out.println("data : " + dataString);
String audioObject = null;
String subtitleObject = null;
JSONObject dataObject = JSONObject.parseObject(dataString);
JSONArray array = dataObject.getJSONArray("result");
for (Object obj : array) {
JSONObject jsonObject = (JSONObject)obj;
String object = jsonObject.getString("file");
if (object.endsWith("mp3")) {
audioObject = object;
} else if (object.endsWith("txt")) {
subtitleObject = object;
}
}
System.out.println("audioObject : " + audioObject);
System.out.println("subtitleObject : " + subtitleObject);
// 获取字幕内容
String subtitleContent = getObjectContent(subtitleObject);
System.out.println("subtitleContent : " + subtitleContent);
// 组装字幕轨
if (fontSize <= 0) {
fontSize = 32;
}
if (Strings.isBlank(fontName)) {
fontName = "WenQuanYi Zen Hei Mono";
}
if (Strings.isBlank(fontColor)) {
fontColor = "#000000";
}
for (int j = 0; j < targetCount; j++) {
// 每次循环将视频素材随机,并提交合成任务
Collections.shuffle(videoUrls);
JSONArray subtitleTrackClips = new JSONArray();
JSONArray mpsSubtitles = JSONArray.parseArray(subtitleContent);
JSONArray videoTrackClips = new JSONArray();
// 字幕距离底部距离
float subtitleBottom = 0.25f;
// 随机特效,更多特效见:https://help.aliyun.com/document_detail/207059.html
List vfxs = Arrays.asList("heartfireworks", "colorfulradial", "meteorshower", "starry", "colorfulstarry", "moons_and_stars", "flyfire", "starexplosion", "spotfall", "sparklestarfield");
// 随机转场,更多转场见:https://help.aliyun.com/document_detail/204853.html
List transitions = Arrays.asList("windowslice", "displacement", "bowTieVertical", "linearblur", "waterdrop", "polka", "wiperight", "gridflip", "hexagonalize", "windowblinds", "风车");
float transDuration = 0.3f;
Collections.shuffle(vfxs);
for (int i = 0; i < mpsSubtitles.size(); i++) {
JSONObject mpsSubtitle = mpsSubtitles.getJSONObject(i);
String content = mpsSubtitle.getString("text");
content = content.replaceAll("。", "");
Float timelineIn = mpsSubtitle.getFloat("begin_time") / 1000;
Float timelineOut = mpsSubtitle.getFloat("end_time") / 1000;
String subtitleClip = "{\"Content\":\"" + content + "\",\"TimelineIn\":" + timelineIn + ",\"TimelineOut\":" + timelineOut +
",\"Type\":\"Text\",\"X\":0.0,\"Y\":" + subtitleBottom + ",\"Font\":\"" + fontName + "\",\"Alignment\":\"BottomCenter\",\"FontSize\":" + fontSize +
",\"FontColor\":\"" + fontColor + "\",\"OutlineColour\":\"#000000\",\"FontColor\":\"#ffffff\",}";
subtitleTrackClips.add(JSONObject.parseObject(subtitleClip));
// 根据字幕轨,截取视频轨片段
float out = timelineOut - timelineIn;
// 随机特效
String vfx = vfxs.get(i % vfxs.size());
String transition = transitions.get(i % transitions.size());
String url = videoUrls.get(i % videoUrls.size());
JSONObject clip = new JSONObject();
clip.put("MediaURL", url);
if (url.endsWith(".jpg")) {
clip.put("Duration", out + transDuration);
clip.put("Type", "Image");
} else {
clip.put("Out", out + transDuration);
}
JSONArray effects = new JSONArray();
// 添加背景模糊
effects.add(JSONObject.parseObject("{\"Type\":\"Background\",\"SubType\":\"Blur\",\"Radius\":0.1}"));
// 添加氛围类特效
effects.add(JSONObject.parseObject("{\"Type\":\"VFX\",\"SubType\":\"" + vfx + "\"}"));
// 视频静音
effects.add(JSONObject.parseObject("{\"Type\":\"Volume\",\"Gain\":0}"));
// 添加转场
effects.add(JSONObject.parseObject("{\"Type\":\"Transition\",\"SubType\":\"" + transition + "\",\"Duration\":"+transDuration+"}"));
clip.put("Effects", effects);
videoTrackClips.add(clip);
}
if (title != null && title.length() > 0) {
float titleY = 280;
int titleFontSize = 70;
String titleFont = "AlibabaPuHuiTi";
String titleClip = "{\"Type\":\"Text\",\"X\":0,\"Y\":" + titleY + ",\"Font\":\"" + titleFont + "\",\"Content\":\"" + title + "\",\"Alignment\":\"TopCenter\",\"FontSize\":" + titleFontSize + ",\"FontColor\":\"#FFD700\",\"Outline\":4,\"OutlineColour\":\"#000000\",\"FontFace\":{\"Bold\":true,\"Italic\":false,\"Underline\":false}}";
subtitleTrackClips.add(JSONObject.parse(titleClip));
}
if (subtitle != null && subtitle.length() > 0) {
float subtitleY = 200;
int subtitleFontSize = 60;
String subtitleFont = "AlibabaPuHuiTi";
String subtitleClip = "{\"Type\":\"Text\",\"X\":0,\"Y\":" + subtitleY + ",\"Font\":\"" + subtitleFont + "\",\"Content\":\"" + subtitle + "\",\"Alignment\":\"TopCenter\",\"FontSize\":" + subtitleFontSize + ",\"FontColorOpacity\":1,\"FontColor\":\"#ffffff\",\"Outline\":2,\"OutlineColour\":\"#000000\",\"FontFace\":{\"Bold\":false,\"Italic\":false,\"Underline\":false}}";
subtitleTrackClips.add(JSONObject.parse(subtitleClip));
}
// 组装音频轨
String audioUrl = "http://" + bucket + ".oss-" + regionId + ".aliyuncs.com/" + audioObject;
String audioTrackClips = "";
if (Strings.isBlank(bgMusic)) {
audioTrackClips = "[{\"MediaURL\":\"" + audioUrl + "\"}]";
} else {
// 两个音频轨,一个人声,一个音乐
audioTrackClips = "[{\"MediaURL\":\"" + audioUrl + "\"}]},{\"AudioTrackClips\":[{\"MediaURL\":\"" + bgMusic + "\"}]";
}
// 图片轨,用于展示logo
String logoClip = "";
int logoWidth = 200;
int logoHeight = 60;
if (Strings.isNotBlank(logoUrl)) {
logoClip = "{\"ImageURL\":\"" + logoUrl + "\",\"X\":" + logoX + ",\"Y\":"
+ logoY + ",\"Width\":\"" + logoWidth + "\",\"Height\":\"" + logoHeight + "\"}";
}
// 拼时间线
String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoTrackClips.toJSONString() + "}]," +
"\"SubtitleTracks\":[{\"SubtitleTrackClips\":" + subtitleTrackClips.toJSONString() + "}]," +
"\"AudioTracks\":[{\"AudioTrackClips\":" + audioTrackClips + "}]," +
"\"ImageTracks\":[{\"ImageTrackClips\":[" + logoClip + "]}]}";
System.out.println("timeline : " + timeline);
// 提交合成任务
SubmitMediaProducingJobRequest submitMediaProducingJobRequest = new SubmitMediaProducingJobRequest();
submitMediaProducingJobRequest.setTimeline(timeline);
String outputPath = IceUtil.getRandomOutputPath();
String mediaURL = "https://" + bucket + ".oss-" + regionId + ".aliyuncs.com/" + outputPath + ".mp4";
submitMediaProducingJobRequest.setOutputMediaConfig("{\"MediaURL\":\"" + mediaURL + "\",\"Width\":" + width + ",\"Height\":" + height + "}");
Client iceClient = TestClientInstance.getInstance().getIceClient();
SubmitMediaProducingJobResponse submitMediaProducingJobResponse = iceClient.submitMediaProducingJob(submitMediaProducingJobRequest);
System.out.println("job created, jobId : " + submitMediaProducingJobResponse.body.jobId + ", requestId : " + submitMediaProducingJobResponse.body.getRequestId() + ", mediaURL : " + mediaURL);
// 等待合成任务完成
while (true) {
GetMediaProducingJobRequest getMediaProducingJobRequest = new GetMediaProducingJobRequest();
getMediaProducingJobRequest.setJobId(submitMediaProducingJobResponse.body.jobId);
GetMediaProducingJobResponse getMediaProducingJobResponse = iceClient.getMediaProducingJob(getMediaProducingJobRequest);
System.out.println("GetMediaProducingJobResponse : " + JSONObject.toJSONString(getMediaProducingJobResponse.body));
String status = getMediaProducingJobResponse.getBody().getMediaProducingJob().getStatus();
if ("Success".equals(status)) {
break;
}
Thread.sleep(5000);
}
System.out.println("Produce succeed : " + mediaURL);
}
}
public com.aliyun.mts20140618.Client createMpsClient() throws Exception {
String accessKeyId = TestClientInstance.getInstance().getAk();
String accessKeySecret = TestClientInstance.getInstance().getSk();
Config config = new Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(accessKeySecret);
config.endpoint = "mts." + regionId + ".aliyuncs.com";
return new com.aliyun.mts20140618.Client(config);
}
public OSS createOssClient() {
String endpoint = "http://oss-" + regionId + ".aliyuncs.com";
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
return ossClient;
}
public String getObjectContent(String object) throws Exception {
OSSObject obj = ossClient.getObject(bucket, object);
InputStream stream = obj.getObjectContent();
String result = CharStreams.toString(new InputStreamReader(stream, Charsets.UTF_8));
return result;
}
public com.aliyun.ice20201109.Client createIceClient() throws Exception {
Config config = new Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(accessKeySecret);
config.endpoint = "ice." + regionId + ".aliyuncs.com";
return new com.aliyun.ice20201109.Client(config);
}
public void putObjectContent(String object, String content) throws Exception {
PutObjectRequest putObjectRequest = new PutObjectRequest(bucket, object, new ByteArrayInputStream(content.getBytes()));
ossClient.putObject(putObjectRequest);
}
}