视频剪辑Web端Demo只包含了视频剪辑Web SDK最基本的功能,您可以根据实际需求在此基础上扩展。通过阅读本文,您可以了解Web SDK的扩展功能示例。
目录
扩展功能示例
在扩展Demo的基本功能时,需要修改fe/src/ProjectDetail.jsx文件对应的代码。以下为常用功能的示例:
动态获取视频剪辑Web SDK的版本号
如果代码中需要使用视频剪辑Web SDK的版本号,建议动态获取。
window.AliyunVideoEditor.version
自定义字幕默认文字
添加字幕时,默认字幕内容为“阿里云剪辑”,可以通过传入参数defaultSubtitleText
自定义默认字幕内容(字幕长度不超过20)。
window.AliyunVideoEditor.init({
// 其他选项省略
defaultSubtitleText: '自定义字幕默认文字'
})
自定义按钮文案
视频剪辑界面的导入素材、保存和导出视频按钮支持自定义文案,可以通过传入参数customTexts
实现。
window.AliyunVideoEditor.init({
// 其他选项省略
customTexts: {
importButton: '自定义导入',
updateButton: '自定义保存',
produceButton: '自定义生成'
}
})
修改默认预览画布比例
默认预览画布比例为16∶9,可以通过传入参数defaultAspectRatio
自定义默认预览画布比例。支持的画布比例请参见PlayerAspectRatio。
window.AliyunVideoEditor.init({
// 其他选项省略
defaultAspectRatio: '9:16'
})
主动获取Timeline数据
主动获取Timeline数据后,如果对其数据进行修改,必须保证Timeline正确性,避免调用服务端接口出错。
window.AliyunVideoEditor.getProjectTimeline()
自定义返回按钮
默认视频剪辑界面左上角没有返回按钮,可以通过实现响应函数onBackButtonClick
自定义单击返回按钮之后的逻辑。
window.AliyunVideoEditor.init({
// 其他选项省略
onBackButtonClick: () => {
window.location.href = '/mediaEdit/list'; // 点击后跳转到其他页面,如工程列表页
}
})
自定义Logo
默认视频剪辑界面左上角没有Logo图标,可以通过传入参数customTexts
自定义Logo图标。
window.AliyunVideoEditor.init({
// 其他选项省略
customTexts: {
logoUrl: 'https://www.example.com/assets/example-logo-url.png'
}
})
自定义媒资导入界面
单击导入素材按钮后,可以通过实现函数searchMedia
添加媒资导入界面。实现逻辑:搜索媒资信息后将媒资库媒资导入到素材,再调用AddEditingProjectMaterials将选中的素材与工程关联起来,返回的Promise对象需要resolve新增素材的数组。详细示例请参见Demo中fe/src/SearchMediaModal.jsx文件。
自定义合成导出界面
单击导出视频按钮后,可以通过实现函数produceEditingProjectVideo
添加合成导出界面。实现逻辑:调出配置合成参数页面后,该页面上的提交按钮的消息响应为提交剪辑合成作业接口SubmitMediaProducingJob,返回的Promise对象需要resolve。详细示例请参见Demo中fe/src/ProduceVideoModal.jsx文件。
除了实现上述功能外,函数produceEditingProjectVideo
也可以固定合成参数(例如:存储的Bucket、视频格式等),阻止他人随意修改,或在导出视频前对Timeline进行业务层面的校验。示例如下所示:
window.AliyunVideoEditor.init({
// 省略其他选项
produceEditingProjectVideo: ({ timeline }) => { // 用户点击“导出视频”按钮时该方法会被调用
// 找出类型为字幕且轨道内片段数大于 0 的所有轨道
const subtitleTracks = timeline.VideoTracks.filter((t) => t.Type === 'Subtitle' && t.VideoTrackClips.length > 0);
if (subtitleTracks.length < 2) {
// 满足要求的轨道数量小于 2,报错提示并直接 return
console.error('非空字幕轨道数量小于 2');
return;
} else {
// 满足要求,省略后续对服务端发起合成请求的步骤
}
},
});
智能生成字幕
默认视频剪辑界面没有智能生成字幕按钮,可以通过传入参数AsrConfig
实现。详情请参见Demo代码。
window.AliyunVideoEditor.init({
// 省略其他选项
asrConfig: {
interval: 5000,
submitASRJob: async (mediaId, startTime, duration) => {
const res = await request("SubmitASRJob", {
InputFile: mediaId,
StartTime: startTime,
Duration: duration,
});
const jobId = get(res, "data.JobId");
return { jobId: jobId, jobDone: false };
},
getASRJobResult: async (jobId) => {
const res = await request("GetSmartHandleJob", {
JobId: jobId,
});
const isDone = get(res, "data.State") === "Finished";
const isError = get(res, "data.State") === "Failed";
let result;
if (res.data && res.data?.Output) {
result = JSON.parse(res.data?.Output);
}
return {
jobId,
jobDone: isDone,
result,
jobError: isError ? "智能任务失败" : undefined,
};
},
},
});
媒资标记
传入媒资标记
导入媒资时传入媒资标记
在Web SDK中的
getEditingProjectMaterials
函数中将OpenAPI的媒资格式转换成SDK对应的格式,同时传入媒资标记相关的转换。const markData = item.MediaDynamicInfo.DynamicMetaData.Data; if (markData) { const dataObject = JSON.parse(markData); marks = dataObject.MediaMark.map((m) => ({ startTime: m.MarkStartTime, endTime: m.MarkEndTime, content: m.MarkContent, })); result.video.marks = marks; }
提交合成时传入媒资标记
将媒资标记mediaMarks转换成OpenAPI对应的格式,在调用接口SubmitMediaProducingJob提交剪辑合成作业时传入SDK传来媒资标记。
if (mediaMarks.length !== 0) { values.MediaMarks = mediaMarks.map((mark) => ({ MarkStartTime: mark.startTime, MarkEndTime: mark.endTime, MarkContent: mark.content, })); } const res = await request('SubmitMediaProducingJob', { ...values, });
各标记片段独立导出
将选中标记片段分别导出为多个独立视频,单击导出为视频片段后,弹窗引导用户设置多个独立视频的名称、存储地址、格式、分辨率、码率等参数。可复用导出视频弹窗。
window.AliyunVideoEditor.init({ ... exportFromMediaMarks: async (data) => { // 标记片段独立导出例子 const projectId = ''; //填空字符串,不为空可能覆盖当前项目timeline // 以下参数可复用导出视频的弹框对参数进行处理,生成合成任务请求参数 const reqParams = data.map((item, index) => { return { ProjectId: projectId, Timeline: JSON.stringify(item.timeline), OutputMediaTarget: 'oss-object', OutputMediaConfig: JSON.stringify({ //设置业务文件名,导出多个可根据序号设置 MediaURL: `https://example-bucket.oss-cn-shanghai.aliyuncs.com/example_${index}.mp4`, }), ....//其他业务参数 }; }); //提交多个合成任务 await Promise.all( reqParams.map(async (params) => { //业务方自定义请求提交合成的API request('SubmitMediaProducingJob',params) }), ); }, ... })
拆条及导出
选中轨道区多个音视频片段,单击右上角导出为,下拉框对应功能如下所示:
各片段独立导出
将选中片段分别导出为多个独立视频,单击各片段独立导出后,弹窗引导用户设置多个独立视频的名称、存储地址、格式、分辨率、码率等参数。可复用导出视频弹窗。
window.AliyunVideoEditor.init({ ... exportVideoClipsSplit: async (data) => { // 片段独立导出例子 const projectId = ''; //填空字符串,不为空可能覆盖当前项目timeline // 以下参数可复用导出视频的弹框对参数进行处理,生成合成任务请求参数 const reqParams = data.map((item, index) => { return { ProjectId: projectId, Timeline: JSON.stringify(item.timeline), OutputMediaTarget: 'oss-object', OutputMediaConfig: JSON.stringify({ //设置业务文件名,导出多个可根据序号设置 MediaURL: `https://example-bucket.oss-cn-shanghai.aliyuncs.com/example_${index}.mp4`, }), ....//其他业务参数 }; }); //提交多个合成任务 await Promise.all( reqParams.map(async (params) => { //业务方自定义请求提交合成的API request('SubmitMediaProducingJob',params) }), ); }, ... })
片段合成导出
将选中片段按顺序前后连接导出为一个新视频,点击后服务端设置默认值提交合成作业或打开合成设置弹窗,单击片段合成导出后,弹窗引导用户设置视频的名称、存储地址、格式、分辨率、码率等参数。可复用导出视频弹窗。
window.AliyunVideoEditor.init({ ... exportVideoClipsMerge: async (data) => { // 片段合成导出例子 const projectId = '';// 填空字符串,不为空可能覆盖当前项目timeline // 以下参数可复用导出视频的弹框对参数进行处理,生成合成任务请求参数 const reqParam = { ProjectId: projectId, Timeline: JSON.stringify(data.timeline), OutputMediaTarget: 'oss-object', OutputMediaConfig: JSON.stringify({ //设置业务文件名 MediaURL: 'https://example-bucket.oss-cn-shanghai.aliyuncs.com/example.mp4', }), }; //业务方自定义请求提交合成的API await request('SubmitMediaProducingJob', reqParam); }, ... })
导出视频
将整个时间轨的全部素材按图层结构、时间顺序及指定效果导出为一个新视频,详情请参见自定义合成导出界面。
智能生成配音
默认视频剪辑界面没有智能配音按钮,可以通过传入参数ttsConfig
实现。详情请参见Demo代码。
window.AliyunVideoEditor.init({
// 省略其他选项
ttsConfig: {
interval: 3000,
submitAudioProduceJob: async (text, voice, voiceConfig = {}) => {
const storageListReq = await requestGet("GetStorageList");
const tempFileStorageLocation =
storageListReq.data.StorageInfoList.find((item) => {
return item.EditingTempFileStorage;
});
if (!tempFileStorageLocation) {
throw new Error("未设置临时存储路径");
}
const { StorageLocation, Path } = tempFileStorageLocation;
// 智能生成配音会生成一个音频文件存放到接入方的 OSS 上,这里 bucket, path 和 filename 是一种命名的示例,接入方可以自定义
const bucket = StorageLocation.split(".")[0];
const path = Path;
const filename = `${text.slice(0, 10)}${Date.now()}`;
const editingConfig = voiceConfig.custom
? {
customizedVoice: voice,
format: "mp3",
...voiceConfig,
}
: {
voice,
format: "mp3",
...voiceConfig,
};
// 1-提交智能配音任务
const res1 = await request("SubmitAudioProduceJob", {
// https://help.aliyun.com/document_detail/212273.html
EditingConfig: JSON.stringify(editingConfig),
InputConfig: text,
OutputConfig: JSON.stringify({
bucket,
object: `${path}${filename}`,
}),
});
if (res1.status !== 200) {
return { jobDone: false, jobError: "暂未识别当前文字内容" };
} else {
const jobId = get(res1, 'data.JobId');
return { jobId: jobId, jobDone: false };
}
},
getAudioJobResult: async (jobId) => {
const res = await requestGet("GetSmartHandleJob",{
JobId: jobId,
});
const isJobDone = get(res, 'data.State') === 'Finished';
let isMediaReady = false;
let isError = get(res, 'data.State') === 'Failed';
let result;
let audioMedia;
let mediaId;
let asr = [];
if (res.data && res.data?.JobResult) {
try {
result = res.data.JobResult;
mediaId = result.MediaId;
if (result.AiResult) {
asr = JSON.parse(result.AiResult);
}
} catch (ex) {
console.error(ex);
}
}
if (!mediaId && res.data && res.data.Output) {
mediaId = res.data.Output;
}
const defaultErrorText = '抱歉,暂未识别当前文字内容';
if (mediaId) {
const mediaRes = await request("GetMediaInfo",{
MediaId: mediaId,
});
if (mediaRes.status !== 200) {
isError = true;
}
const mediaStatus = get(mediaRes, 'data.MediaInfo.MediaBasicInfo.Status');
if (mediaStatus === 'Normal') {
isMediaReady = true;
const transAudios = transMediaList([get(mediaRes, 'data.MediaInfo')]);
audioMedia = transAudios[0];
if (!audioMedia) {
isError = true;
}
} else if (mediaStatus && mediaStatus.indexOf('Fail') >= 0) {
isError = true;
}
} else if (isJobDone) {
isError = true;
}
return {
jobId,
jobDone: isJobDone && isMediaReady,
result: audioMedia,
asr,
jobError: isError ? defaultErrorText : undefined,
};
}
},
});
自定义字体列表
默认视频剪辑支持的字体为阿里云官方字体,如下所示:
// 官方支持的字体列表,及中文对照表
const FONT_FAMILIES = [
'alibaba-sans', // 阿里巴巴普惠体
'fangsong', // 仿宋字体
'kaiti', // 楷体
'SimSun', // 宋体
'siyuan-heiti', // 思源黑体
'siyuan-songti', // 思源宋体
'wqy-zenhei-mono', // 文泉驿等宽正黑
'wqy-zenhei-sharp', // 文泉驿点阵正黑
'wqy-microhei', // 文泉驿微米黑
'zcool-gaoduanhei', // 站酷高端黑体
'zcool-kuaile', // 站酷快乐体
'zcool-wenyiti', // 站酷文艺体
];
如果您需要展示部分或重新排列阿里云官方字体,可以通过传入参数
customFontList
实现。window.AliyunVideoEditor.init({ // ... 其他选项省略 customFontList: [ // 只使用这些字体并按此顺序展示 'SimSun', 'kaiti', 'alibaba-sans', 'zcool-kuaile', 'wqy-microhei', ] });
如果您需要使用自有字体,且自有字体存储在OSS Bucket上,可以通过传入参数
customFontList
实现。重要请确保OSS Bucket对应的账号和提交合成操作的账号一致,否则提交合成时无法下载字体。
window.AliyunVideoEditor.init({ // ... 其他选项省略 customFontList: [ // 只使用这些官方字体和自己的字体 'SimSun', 'kaiti', 'alibaba-sans', 'zcool-kuaile', 'wqy-microhei', { key: '阿朱泡泡体', // 需要是唯一的key,不能与其他字体相同,中英文均可 name: '阿朱泡泡体', // 展示在页面的名称 // url 是字体文件的地址 url: 'https://test-shanghai.oss-cn-shanghai.aliyuncs.com/xxxxx/阿朱泡泡体.ttf', }, { key: 'HussarBoldWeb', // 需要是唯一的key,不能与其他字体相同,中英文均可 name: 'HussarBoldWeb', // 展示在页面的名称 // url 是字体文件的地址 url: 'https://test-shanghai.oss-cn-shanghai.aliyuncs.com/xxxxx/HussarBoldWeb.ttf', } ], /** * 若您的字体地址是动态的,可以使用 getDynamicSrc 方法返回对应的动态地址 * 若您其他地方用到了 getDynamicSrc 也需要处理字体的情况 * * @param {string} mediaId 当时字体时返回customFontList每项的 key,如 HussarBoldWeb * @param {string} mediaType 素材类型,字体时 font * @param {string} mediaOrigin 素材来源,主要是区分公共还是私有素材,字体逻辑没有暂时没有用到,所以是 undefined * @param {string} InputURL 字体时输入的字体地址 * @returns {Promise<string>} 文件真正可用的地址 */ getDynamicSrc: (mediaId, mediaType, mediaOrigin, InputURL) { // 如果字体的oss bucket不是动态,可直接返回输入的地址 // if (mediaType === 'font') { // return Promise.resolve(InputURL); // } // 处理字体类型的伪代码 if (mediaType === 'font') { return api.getFontUrl({ id: mediaId, url: InputURL }).then((res) => { return res.data.url; }); } // ... 此处省略处理 video、audio 等其他类型的素材 } });
如果您需要使用自有字体,且自有字体存储在IMS媒资库上,可以通过传入参数
customFontList
实现。重要请确保IMS媒资库对应的账号和提交合成操作的账号一致,否则提交合成时无法下载字体。
window.AliyunVideoEditor.init({ // ... 其他选项省略 getDynamicSrc: (mediaId, mediaType, mediaOrigin, InputURL) => { const params = { MediaId: mediaId, OutputType: 'cdn' }; // 从媒资库动态获取字体地址的例子,使用 InputURL 查询 if (mediaType === 'font') { params.InputURL = InputURL; delete params.MediaId; } return request('GetMediaInfo', params).then((res) => { // 注意,这里仅作为示例,实际中建议做好错误处理,避免如 FileInfoList 为空数组时报错等异常情况 return res.data.MediaInfo.FileInfoList[0].FileBasicInfo.FileUrl; }); }; });
分离视频音轨
如果您需要将属性编辑区基础页签下的分离视频音轨按钮设置为可用,至少需要满足以下条件之一:
当前视频媒资含有代理音频。声明媒资含有代理音频的方式是添加标记
hasTranscodedAudio=true
,按照生效的粒度和优先级分为以下两种声明方式:(优先级较高,推荐)为导入工程的视频媒资添加“该媒资含有代理音频”的标记,即
Media.video.hasTranscodedAudio=true
,此标记位仅针对单个视频素材生效,表明该媒资进行了预处理,生成了代理音频。(优先级较低)剪辑器初始化时进行全局声明,即
config.hasTranscodedAudio=true
,表示所有导入工程中的视频素材均有代理音频,此标记位针对所有视频素材生效。如果该媒资没有Media.video.hasTranscodedAudio
标记,则全局性声明生效,该素材可以操作分离音频轨,否则以Media.video.hasTranscodedAudio
标记为准。
当前视频媒资含有音频轨,且视频原始时长不大于30分钟。
如何生成代理音频
如果您的媒资存储在智能媒体服务中,可以使用媒体处理服务对视频进行音频转码,转码结束后,会在媒资详情页的视频地址页签下生成的代理音频。
您可以通过以下方式对视频进行音频转码:
在媒资管理页面单击操作列的媒体处理,选择音频转码相关的转码模板或工作流。
在点播媒体处理任务管理页面中创建音频转码任务。
在上传音视频页面中媒体处理选择上传后,自动进行媒体处理,并选择音频转码相关的工作流。
如何为视频添加“该媒资含有代理音频”的标记
在导入媒资时,接口searchMedia(导入素材)和getEditingProjectMaterials(获取工程关联素材)需要在数据转换时查找媒资播放信息中是否带有转码后的音频。
// 注意,Web SDK 本身并不提供 request 这个方法,这里仅作为示例,您可以使用您喜欢的网络请求库,如 axios 等
window.AliyunVideoEditor.init({
...,
getEditingProjectMaterials: () => {
if (projectId) { // projectId 由调用方自己保存
return request('GetEditingProjectMaterials', { // https://help.aliyun.com/document_detail/209068.html
ProjectId: projectId
}).then((res) => {
const data = res.data.MediaInfos;
return transMediaList(data); // 需要做一些数据变换,具体参考后文
});
}
return Promise.resolve([]);
},
...
});
/**
* 将服务端的素材信息转换成 Web SDK 需要的格式
* 在这个方法中,您可以为视频素材添加 hasTranscodedAudio 属性,标记该媒资是否含有代理音频
*/
function transMediaList(data) {
if (!data) return [];
if (Array.isArray(data)) {
return data.map((item) => {
const basicInfo = item.MediaBasicInfo;
const fileBasicInfo = item.FileInfoList[0].FileBasicInfo;
const mediaId = basicInfo.MediaId;
const result = {
mediaId
};
const mediaType = basicInfo.MediaType
result.mediaType = mediaType;
if (mediaType === 'video') {
result.video = {
title: fileBasicInfo.FileName,
...,
// 是否含有代理音频标记
hasTranscodedAudio: !!getTranscodedAudioFileFromFileInfoList(item?.FileInfoList || []),
// 若useDynamicSrc=false时,需要传入代理音频地址,否则不传入
agentAudioSrc: '*'
};
...
} else if (mediaType === 'audio') {
...
} else if (mediaType === 'image') {
...
}
return result;
});
} else {
return [data];
}
}
/**
* 从视频媒资的 FileInfoList 中获取转码后的音频 FileInfo
* @param {list<FileInfo>} fileInfoList
* @returns FileInfo | undefined
* ListMediaBasicInfos/SearchMedia 接口 MediaInfo.FileInfoList 中只包含源文件,GetMediaInfo/BatchGetMediaInfos 接口则包含所有流
*/
export const getTranscodedAudioFileFromFileInfoList = (fileInfoList = []) => {
if (!fileInfoList.length) return;
// 用 FileType === 'transcode_file' 过滤出转码音频
const transcodedAudioFiles = fileInfoList.filter((item = {}) => {
return (
item?.FileBasicInfo?.FileType === 'transcode_file' &&
getFileType(item?.FileBasicInfo?.FileName) === MEDIA_TYPE.AUDIO
);
});
if (transcodedAudioFiles.length) {
const mp3FileInfo = fileInfoList.find(
(item = {}) => getFileExtension(item?.FileBasicInfo?.FileName).toUpperCase() === 'MP3'
);
// 优先返回 mp3
return mp3FileInfo || transcodedAudioFiles[0];
}
};
如何加载代理音频
根据剪辑器是否动态获取媒资URL,含有代理音频的视频媒资在分离音频轨时,Web SDK行为如下:
(常见)动态获取资源src,分离出音频轨后会对音频src进行拉取,该值是传入SDK的接口getDynamicSrc的返回值。需要注意的是当视频含有“该媒资含有代理音频”标记,或全局指定
config.hasTranscodedAudio=true
时,Web SDK会无条件使用接口getDynamicSrc的src,因此根据getDynamicSrc的参数字段mediaType
返回正确的音频地址,可以提升加载和绘制波形图的速度。静态资源src可在视频数据中记录代理音频的地址
Media.video.agentAudioSrc = {agent-audio-static-src}
。需要注意的是当视频含有“该媒资含有代理音频”标记,或全局指定config.hasTranscodedAudio=true
时,Web SDK会无条件地使用agentAudioSrc || src
用于音频波形绘制,因此确保agentAudioSrc
值为正确的音频地址,可以提升加载和绘制波形图的速度。
接入数字人
云剪辑Web SDK接入数字人功能时,需要更新接口getDynamicSrc
,配置数字人接入参数avatarConfig
。
getDynamicSrc
接入数字人视频当前会生成两个视频,其一为带绿幕的原视频;其二为黑白遮罩视频,用于透明背景合成。您需要把数字人的遮罩视频提取出来作为透明遮罩传给云剪辑Web SDK,才能实现数字人背景透明的效果。
avatarConfig
数字人接入配置项
描述
outputConfigs
根据业务需要可以给数字人视频设置输出的分辨率和码率。
filterOutputConfig
根据业务需要过滤不同数字人输出的分辨率,当调用ListSmartSysAvatarModels获取系统数字人列表,返回参数
OutputMask
为false时,此时输出分辨率只支持1920×1080和1080×1920两种分辨率。refreshInterval
设置数字人任务轮询时间,单位:毫秒。
getAvatarList
调用接口ListSmartSysAvatarModels获取官方数字人列表。
submitAvatarVideoJob
提交数字人合成任务。如果使用临时路径保存视频文件,需要先在IMS控制台设置临时路径。
getAvatarVideoJob
获取数字人合成任务状态。数字人合成任务启动后,Web SDK会根据任务轮询时间自动调用
getAvatarVideoJob
,当任务完成时,请确保媒资库中遮罩视频和绿幕视频都已生成,每次轮询都会返回任务当前状态。getAvatar
根据数字人ID获取数字人信息。
window.AliyunVideoEditor.init({ // 更改动态获取url逻辑,需要把数字人的遮罩视频提取出来作为透明遮罩给到websdk getDynamicSrc: (mediaId, mediaType) => { return request('GetMediaInfo', { // https://help.aliyun.com/document_detail/197842.html MediaId: mediaId }).then((res) => { // 注意,这里仅作为示例,实际中建议做好错误处理,避免如 FileInfoList 为空数组时报错等异常情况 const fileInfoList = get(res, 'data.MediaInfo.FileInfoList', []); let mediaUrl,maskUrl; let sourceFile = fileInfoList.find((item)=>{ return item?.FileBasicInfo?.FileType === 'source_file'; }) if(!sourceFile){ sourceFile = fileInfoList[0] } const maskFile = fileInfoList.find((item)=>{ return ( item.FileBasicInfo && item.FileBasicInfo.FileUrl && item.FileBasicInfo.FileUrl.indexOf('_mask') > 0 ); }); if(maskFile){ maskUrl = get(maskFile,'FileBasicInfo.FileUrl'); } mediaUrl = get(sourceFile,'FileBasicInfo.FileUrl'); if(!maskUrl){ return mediaUrl; } return { url: mediaUrl, maskUrl } }) }, // 数字人接入配置 avatarConfig: { // 视频输出分辨率码率 filterOutputConfig: (item, configs) => { if (item.outputMask === false) { return [ { width: 1920, height: 1080, bitrates: [4000] }, { width: 1080, height: 1920, bitrates: [4000] }, ]; } return configs; }, // 任务轮询时间(单位毫秒) refreshInterval: 2000, // 获取官方数字人列表 getAvatarList: () => { return [ { id: "default", default: true, name: "官方数字人", getItems: async (pageNo, pageSize) => { const res = await requestGet("ListSmartSysAvatarModels", { PageNo: pageNo, PageSize: pageSize, SdkVersion: window.AliyunVideoEditor.version, }); if (res && res.status === 200) { return { total: get(res, "data.TotalCount"), items: get(res, "data.SmartSysAvatarModelList", []).map( (item) => { return { avatarName: item.AvatarName, avatarId: item.AvatarId, coverUrl: item.CoverUrl, videoUrl: item.VideoUrl, outputMask: item.OutputMask, }; } ), }; } return { total: 0, items: [], }; }, }, { id: "custom", default: false, name: "我的数字人", getItems: async (pageNo, pageSize) => { const res = await requestGet("ListAvatars", { PageNo: pageNo, PageSize: pageSize, SdkVersion: window.AliyunVideoEditor.version, }); if (res && res.status === "200") { const avatarList = get(res, "data.Data.AvatarList", []); const coverMediaIds = avatarList.map((aitem) => { return aitem.Portrait; }); const coverListRes = await requestGet("BatchGetMediaInfos", { MediaIds: coverMediaIds.join(","), AdditionType: "FileInfo", }); const mediaInfos = get(coverListRes, "data.MediaInfos"); const idCoverMapper = mediaInfos.reduce((result, m) => { result[m.MediaId] = get( m, "FileInfoList[0].FileBasicInfo.FileUrl" ); return result; }, {}); return { total: get(res, "data.TotalCount"), items: avatarList.map((item) => { return { avatarName: item.AvatarName || "", avatarId: item.AvatarId, coverUrl: idCoverMapper[item.Portrait], videoUrl: undefined, outputMask: false, transparent: item.Transparent, }; }), }; } return { total: 0, items: [], }; }, }, ]; }, // 提交数字人任务 submitAvatarVideoJob: async (job) => { const storageListReq = await requestGet("GetStorageList"); const tempFileStorageLocation = storageListReq.data.StorageInfoList.find((item) => { return item.EditingTempFileStorage; }); if (tempFileStorageLocation) { const { StorageLocation, Path } = tempFileStorageLocation; /** * 判断数字人是否输出背景透明等格式 * outputMask:boolean,需要输出遮罩视频,此时输出的视频格式需要是mp4,会生成一个遮罩视频和纯色背景mp4视频 * transparent: boolean,是否透明视频,如果transparent为false,则表示该数字人是带背景的,不能生成透明背景的webm视频 * */ const { outputMask, transparent } = job.avatar; const filename = outputMask || transparent === false ? `${encodeURIComponent(job.title)}-${Date.now()}.mp4` : `${encodeURIComponent(job.title)}-${Date.now()}.webm`; const outputUrl = `https://${StorageLocation}/${Path}${filename}`; const params = { UserData: JSON.stringify(job), }; if (job.type === "text") { params.InputConfig = JSON.stringify({ Text: job.data.text, }); params.EditingConfig = JSON.stringify({ AvatarId: job.avatar.avatarId, Voice: job.data.params.voice, // 发音人,仅输入为Text有效,必填 SpeechRate: job.data.params.speechRate, // 语速,仅输入为Text有效,取值范围:-500~500,默认值:0 PitchRate: job.data.params.pitchRate, // 音调,仅输入为Text有效,取值范围:-500~500,默认值:0 Volume: job.data.params.volume, }); params.OutputConfig = JSON.stringify({ MediaURL: outputUrl, Bitrate: job.data.output.bitrate, Width: job.data.output.width, Height: job.data.output.height, }); } else { params.InputConfig = JSON.stringify({ MediaId: job.data.mediaId, }); params.EditingConfig = JSON.stringify({ AvatarId: job.avatar.avatarId, }); params.OutputConfig = JSON.stringify({ MediaURL: outputUrl, Bitrate: job.data.output.bitrate, Width: job.data.output.width, Height: job.data.output.height, }); } const res = await request("SubmitAvatarVideoJob", params); if (res.status === 200) { return { jobId: res.data.JobId, mediaId: res.data.MediaId, }; } else { throw new Error("提交任务失败"); } } else { throw new Error("无法获取临时路径"); } }, // 获取数字人任务状态,定时轮询调用 getAvatarVideoJob: async (jobId) => { try { const res = await requestGet("GetSmartHandleJob", { JobId: jobId }); if (res.status !== 200) { throw new Error( `response error:${res.data && res.data.ErrorMsg}` ); } let job; if (res.data.UserData) { job = JSON.parse(res.data.UserData); } let video; let done = false; let subtitleClips; // 解析生成的字幕 if (res.data.JobResult && res.data.JobResult.AiResult) { const apiResult = JSON.parse(res.data.JobResult.AiResult); if ( apiResult && apiResult.subtitleClips && typeof apiResult.subtitleClips === "string" ) { subtitleClips = JSON.parse(apiResult.subtitleClips); } } const mediaId = res.data.JobResult.MediaId; if (res.data.State === "Finished") { // 获取生成的媒资状态 const res2 = await request("GetMediaInfo", { MediaId: mediaId, }); if (res2.status !== 200) { throw new Error( `response error:${res2.data && res2.data.ErrorMsg}` ); } // 判断生成的视频及透明遮罩视频是否成功 const fileLength = get( res2, "data.MediaInfo.FileInfoList", [] ).length; const { avatar } = job; const statusOk = get(res2, "data.MediaInfo.MediaBasicInfo.Status") === "Normal" && (avatar.outputMask ? fileLength >= 2 : fileLength > 0); const result = statusOk ? transMediaList([get(res2, "data.MediaInfo")]) : []; video = result[0]; done = !!video && statusOk; if (done) { // 将新的数字人素材与工程进行绑定 await request("AddEditingProjectMaterials", { ProjectId: projectId, MaterialMaps: JSON.stringify({ video: mediaId, }), }); } } else if (res.data.State === "Failed") { return { done: false, jobId, mediaId, job, errorMessage: `job status fail,status:${res.data.State}`, }; } // 返回任务状态,done后不再轮询 return { done, jobId: res.data.JobId, mediaId, job, video, subtitleClips, }; } catch (ex) { return { done: false, jobId, errorMessage: ex.message, }; } }, getAvatar: async (id) => { const listRes = await requestGet("ListSmartSysAvatarModels", { SdkVersion: window.AliyunVideoEditor.version, PageNo: 1, PageSize: 100, }); const sysAvatar = get( listRes, "data.SmartSysAvatarModelList", [] ).find((item) => { return item.AvatarId === id; }); if (sysAvatar) { return { ...objectKeyPascalCaseToCamelCase(sysAvatar), }; } const res = await requestGet("GetAvatar", { AvatarId: id }); const item = get(res, "data.Data.Avatar"); const coverListRes = await request("BatchGetMediaInfos", { MediaIds: item.Portrait, AdditionType: "FileInfo", }); const mediaInfos = get(coverListRes, "data.MediaInfos"); const idCoverMapper = mediaInfos.reduce((result, m) => { result[m.MediaId] = get(m, "FileInfoList[0].FileBasicInfo.FileUrl"); return result; }, {}); return { avatarName: item.AvatarName || "test", avatarId: item.AvatarId, coverUrl: idCoverMapper[item.Portrait], videoUrl: undefined, outputMask: false, transparent: item.Transparent, }; }, }, })
自定义专属人声
export const transVoiceGroups = (data = []) => {
return data.map(({ Type: type, VoiceList = [] }) => {
return {
type,
voiceList: VoiceList.map((item) => {
const obj = {};
Object.keys(item).forEach((key) => {
obj[lowerFirst(key)] = item[key];
});
return obj;
}),
};
});
};
const customVoiceGroups= await requestGet('ListSmartVoiceGroups').then((res)=>{
const commonItems = transVoiceGroups(get(res, 'data.VoiceGroups', []));
const customItems = [
{
type: '基础',
category: '专属人声', // 专属人声分类,4.12.0以上版本支持
emptyContent: {
description: '暂无人声 可通过',
link: '',
linkText: '创建专属人声',
},
getVoiceList: async (page, pageSize) => {
const custRes = await requestGet('ListCustomizedVoices',{ PageNo: page, PageSize: pageSize });
const items = get(custRes, 'data.Data.CustomizedVoiceList');
const total = get(custRes, 'data.Data.Total');
const kv = {
story: '故事',
interaction: '交互',
navigation: '导航',
};
return {
items: items.map((it) => {
return {
desc: it.VoiceDesc || kv[it.Scenario] || it.Scenario,
voiceType: it.Gender === 'male' ? 'Male' : 'Female',
voiceUrl: it.VoiceUrl || '',
tag: it.VoiceDesc || it.Scenario,
voice: it.VoiceId,
name: it.VoiceName || it.VoiceId,
remark: it.Scenario,
demoMediaId: it.DemoAudioMediaId,
custom: true,
};
}),
total,
};
},
getVoice: async (voiceId) => {
const custRes = await requestGet('GetCustomizedVoice',{ VoiceId: voiceId });
const item = get(custRes, 'data.Data.CustomizedVoice');
const kv = {
story: '故事',
interaction: '交互',
navigation: '导航',
};
return {
desc: item.VoiceDesc || kv[item.Scenario] || item.Scenario,
voiceType: item.Gender === 'male' ? 'Male' : 'Female',
voiceUrl: item.VoiceUrl || '',
tag: item.VoiceDesc || item.Scenario,
voice: item.VoiceId,
name: item.VoiceName || item.VoiceId,
remark: item.Scenario,
demoMediaId: item.DemoAudioMediaId,
custom: true,
};
},
getDemo: async (mediaId) => {
const mediaInfo = await requestGet('GetMediaInfo',{ MediaId: mediaId });
const src = get(mediaInfo, 'data.MediaInfo.FileInfoList[0].FileBasicInfo.FileUrl');
return {
src: src,
};
},
},
{
type: '大众',
category: '专属人声',
emptyContent: {
description: '暂无人声 可通过',
link: '',
linkText: '创建专属人声',
},
getVoiceList: async (page, pageSize) => {
const custRes = await requestGet('ListCustomizedVoices',{ PageNo: page, PageSize: pageSize, Type: 'Standard', });
const items = get(custRes, 'data.Data.CustomizedVoiceList');
const total = get(custRes, 'data.Data.Total');
return {
items: items.map((it) => {
return {
desc: it.VoiceDesc,
voiceType: it.Gender === 'male' ? 'Male' : 'Female',
voiceUrl: it.VoiceUrl || '',
tag: it.VoiceDesc,
voice: it.VoiceId,
name: it.VoiceName || it.VoiceId,
remark: it.Scenario,
demoMediaId: it.DemoAudioMediaId,
custom: true,
};
}),
total,
};
},
getVoice: async (voiceId) => {
const custRes = await requestGet('GetCustomizedVoice',{ VoiceId: voiceId });
const item = get(custRes, 'data.Data.CustomizedVoice');
const kv = {
story: '故事',
interaction: '交互',
navigation: '导航',
};
return {
desc: item.VoiceDesc || kv[item.Scenario] || item.Scenario,
voiceType: item.Gender === 'male' ? 'Male' : 'Female',
voiceUrl: item.VoiceUrl || '',
tag: item.VoiceDesc || item.Scenario,
voice: item.VoiceId,
name: item.VoiceName || item.VoiceId,
remark: item.Scenario,
demoMediaId: item.DemoAudioMediaId,
custom: true,
};
},
getDemo: async (mediaId) => {
const mediaInfo = await requestGet('GetMediaInfo',{ MediaId: mediaId });
const src = get(mediaInfo, 'data.MediaInfo.FileInfoList[0].FileBasicInfo.FileUrl');
return {
src: src,
};
},
},
].concat(commonItems);
return customItems;
})
// 需要等待customVoiceGroups设置后才能开始调用init方法
window.AliyunVideoEditor.init({
...
customVoiceGroups:customVoiceGroups
...
})
公共媒资库
默认视频剪辑界面没有媒资库 的菜单,可以通过传入参数publicMaterials实现。详情请参见Demo代码。
window.AliyunVideoEditor.init({
// 省略其他选项
publicMaterials: {
getLists: async () => {
const resultPromise = [
{
bType: "bgm",
mediaType: "audio",
name: "音乐",
},
{
bType: "bgi",
mediaType: "image",
styleType: "background",
name: "背景",
},
].map(async (item) => {
const res = await request("ListAllPublicMediaTags", {
BusinessType: item.bType,
});
const tagList = get(res, "data.MediaTagList");
return tagList.map((tag) => {
const tagName =
locale === "zh-CN"
? tag.MediaTagNameChinese
: tag.MediaTagNameEnglish;
return {
name: item.name,
key: item.bType,
mediaType: item.mediaType,
styleType: item.styleType,
tag: tagName,
getItems: async (pageNo, pageSize) => {
const itemRes = await request("ListPublicMediaBasicInfos", {
BusinessType: item.bType,
MediaTagId: tag.MediaTagId,
PageNo: pageNo,
PageSize: pageSize,
IncludeFileBasicInfo: true,
});
const total = get(itemRes, "data.TotalCount");
const items = get(itemRes, "data.MediaInfos", []);
const transItems = transMediaList(items);
return {
items: transItems,
end: pageNo * pageSize >= total,
};
},
};
});
});
const resultList = await Promise.all(resultPromise);
const result = resultList.flat();
return result;
},
},
});
媒资异步导入
type InputMedia = (InputVideo | InputAudio | InputImage) ;
interface InputSource {
sourceState?: 'ready' | 'loading' | 'fail'; //表示素材的状态,loading时会展示素材加载中的状态,素材不能添加到轨道中,fail为失败状态,素材不能添加到轨道中,ready时为素材可预览添加的状态,默认状态为ready
}
// .... 参考接入文档的数据结构
// 导入媒资时,如果媒资需要异步处理,例如:转码,生成精灵图等,可以在导入时设置状态为loading
// 例如: searchMedia是返回的素材设置为loading,以下以第三方的自有媒资url导入为例
searchMedia:async()=>{
// 1.选择第三方媒资素材
// 2.调用RegisterMediaInfo将第三方媒资注册到媒资库,获取媒资ID
// 3.调用GetMediaInfo获取注册后的第三方媒资信息,返回loading状态
//.....
return [
{
mediaId: "https://xxxx.xxxxx.mp4",
mediaType: "video",
mediaIdType: "mediaURL",
sourceState: "loading",
video: {
title: "tettesttsete",
coverUrl:"https://xxxxxx.jpg",
duration: 10,
},
},
]
}
// 第三方媒资信息精灵图生成完成后,可以把素材的状态更新
AliyunVideoEditor.updateProjectMaterials((old) => {
return old.map((item) => {
if (item.mediaId === mediaId) {
if ("video" in item) {
item.video.spriteConfig = {
num: "32",
lines: "10",
cols: "10",
};
item.video.sprites = [image]; // image 为生成的精灵图url
item.sourceState = "ready";
}
}
return item;
});
});
视频翻译
模块 | 名称 | 说明 |
translation | 视频翻译 | 视频翻译模块,对接后端视频翻译接口,参考文档:SubmitVideoTranslationJob - 提交视频翻译任务。 |
detext | 字幕擦除 | 字幕擦除模块,对接后端字幕擦除接口,用于单独擦除视频字幕的操作,参考文档:SubmitIProductionJob - 提交智能生产任务。 |
captionExtraction | 字幕提取 | 字幕提取模块,对接后端字幕提取接口,用于单独提取视频字幕的操作,参考文档:SubmitIProductionJob - 提交智能生产任务。 |
接入示例:
window.AliyunVideoEditor.init({
// 省略其他选项
videoTranslation: {
translation: {
submitVideoTranslationJob: async (params) => {
// 这里取的是临时存储地址,业务方接入可以根据业务需求实现
const tempFileStorageLocation = await getTempFileLocation();
if (!tempFileStorageLocation) {
return {
jobDone: false,
jobError: '请设置临时存储地址' ,
};
}
const item = tempFileStorageLocation;
const path = item.Path;
if (params.editingConfig.SourceLanguage !== 'zh') {
return {
jobDone: false,
jobError: '当前仅支持对中文的翻译' ,
};
}
if (params.type === 'Video') { // 针对视频素材进行翻译
const storageType = item.StorageType;
let outputConfig = {
MediaURL: `https://${item.StorageLocation}/${path}videoTranslation-${params.mediaId}.mp4`,
};
if (storageType === 'vod_oss_bucket') {
outputConfig = {
OutputTarget: 'vod',
StorageLocation: get(item, 'StorageLocation'),
FileName: `videoTranslation-${params.mediaId}.mp4`,
TemplateGroupId: 'VOD_NO_TRANSCODE',
};
}
const res = await request("SubmitVideoTranslationJob",{
InputConfig: JSON.stringify({
Type: params.type,
Media: params.mediaId,
}),
OutputConfig: JSON.stringify(outputConfig),
EditingConfig: JSON.stringify(params.editingConfig),
});
return {
jobDone: false,
jobId: res.data.Data.JobId,
};
}
if (params.type === 'Text') {// 针对单个字幕进行翻译
const res = await request("SubmitVideoTranslationJob",{
InputConfig: JSON.stringify({
Type: params.type,
Text: params.text,
}),
EditingConfig: JSON.stringify(params.editingConfig),
});
return {
jobDone: false,
jobId: res.data.Data.JobId,
};
}
if (params.type === 'TextArray') {// 针对字幕数组进行翻译
const res = await request("SubmitVideoTranslationJob",{
InputConfig: JSON.stringify({
Type: params.type,
TextArray: JSON.stringify(params.textArray),
}),
EditingConfig: JSON.stringify(params.editingConfig),
});
return {
jobDone: false,
jobId: res.data.Data.JobId,
};
}
return {
jobDone: false,
jobError: 'not match type',
};
},
getVideoTranslationJob: async (jobId) => {
const resp = await request("GetSmartHandleJob",{
JobId: jobId,
});
const res = resp.data;
if (res.State === 'Executing' || res.State === 'Created') {
return {
jobDone: false,
jobId,
};
}
if (res.State === 'Failed') {
return {
jobDone: true,
jobId,
jobError: '任务执行失败' ,
};
}
let isJobDone = true;
let text;
let textArray;
let timeline;
let jobError;
if (res.JobResult.AiResult) {
const aiResult = JSON.parse(res.JobResult.AiResult);
const projectId1 = aiResult.EditingProjectId;
if (projectId1) {
const projectRes = await request('GetEditingProject',{
ProjectId: projectId1,
RequestSource: 'WebSDK',
});
const timelineConvertStatus = get(projectRes, 'data.Project.TimelineConvertStatus');
if (timelineConvertStatus === 'ConvertFailed') {
jobError = '任务执行失败';
} else if (timelineConvertStatus === 'Converted') {
isJobDone = true;
} else {
isJobDone = false;
}
timeline = projectRes.data.Project.Timeline;
}
text = JSON.parse(res.JobResult.AiResult).TranslatedText;
textArray = JSON.parse(res.JobResult.AiResult).TranslatedTextArray;
}
return {
jobDone: isJobDone,
jobError,
jobId,
result: {
text,
textArray,
timeline,
},
};
},
},
detext: {
submitDetextJob: async ({ mediaId, mediaIdType, box }) => {
const tempFileStorageLocation = await getTempFileLocation();
if (!tempFileStorageLocation) {
return {
jobDone: false,
jobError: '请设置临时存储地址' ,
};
}
const item = tempFileStorageLocation;
const path = item.Path;
const res = await request("SubmitIProductionJob",{
FunctionName: 'VideoDetext',
Input: JSON.stringify({
Type: mediaIdType === 'mediaURL' ? 'OSS' : 'Media',
Media: mediaId,
}),
Output: JSON.stringify({
Type: 'OSS',
Media: `https://${item.StorageLocation}/${path}VideoDetext-${mediaId}.mp4`,
}),
JobParams:
box && box !== 'auto'
? JSON.stringify({
Boxes: JSON.stringify(box),
})
: undefined,
});
return {
jobDone: false,
jobId: res.data.JobId,
};
},
getDetextJob: async (jobId) => {
const resp = await request("QueryIProductionJob",{ JobId: jobId });
const res = resp.data;
if (res.Status === 'Queuing' || res.Status === 'Analysing') {
return {
jobDone: false,
jobId,
};
}
if (res.Status === 'Fail') {
return {
jobDone: true,
jobId,
jobError: intl.get('job_error').d('任务执行失败'),
};
}
const mediaUrl = resp.data.Output.Media;
const mediaInfoRes = await request("GetMediaInfo",{ InputURL: mediaUrl });
if (mediaInfoRes.code !== '200') {
await request("RegisterMediaInfo",{ InputURL: mediaUrl });
return {
jobDone: false,
jobId,
};
}
const mediaStatus = get(mediaInfoRes, 'data.MediaInfo.MediaBasicInfo.Status');
let isError = false;
let isMediaReady = false;
let inputVideo;
if (mediaStatus === 'Normal') {
const transVideo = transMediaList([get(mediaInfoRes, 'data.MediaInfo')]);
inputVideo = transVideo[0];
isMediaReady = true;
if (!inputVideo) {
isError = true;
}
} else if (mediaStatus && mediaStatus.indexOf('Fail') >= 0) {
isError = true;
}
return {
jobDone: isMediaReady,
jobError: isError ? '任务执行失败' : undefined,
jobId: res.JobId,
result: {
video: inputVideo,
},
};
},
},
captionExtraction: {
submitCaptionExtractionJob: async ({ mediaId, mediaIdType, box }) => {
const tempFileStorageLocation = await getTempFileLocation();
if (!tempFileStorageLocation) {
return {
jobDone: false,
jobError: '请选择临时存储地址' ,
};
}
const item = tempFileStorageLocation;
const path = item.Path;
let roi;
if (Array.isArray(box) && box.length > 0 && box[0] && box[0].length === 4) {
const [x, y, width, height] = box[0];
roi = [
[y, y + height],
[x, x + width],
];
}
const res = await request('SubmitIProductionJob',{
FunctionName: 'CaptionExtraction',
Input: JSON.stringify({
Type: mediaIdType === 'mediaURL' ? 'OSS' : 'Media',
Media: mediaId,
}),
Output: JSON.stringify({
Type: 'OSS',
Media: `https://${item.StorageLocation}/${path}CaptionExtraction-${mediaId}.srt`,
}),
JobParams:
box && box !== 'auto'
? JSON.stringify({
roi: roi,
})
: undefined,
});
return {
jobDone: false,
jobId: res.data.JobId,
};
},
getCaptionExtractionJob: async (jobId) => {
const resp = await request('QueryIProductionJob',{ JobId: jobId });
const res = resp.data;
if (res.Status === 'Queuing' || res.Status === 'Analysing') {
return {
jobDone: false,
jobId,
};
}
if (res.Status === 'Fail') {
return {
jobDone: true,
jobId,
jobError: '任务执行失败',
};
}
const mediaUrl = resp.data.OutputUrls[0];
const srtRes = await fetch(mediaUrl.replace('http:', ''));
const srtText = await srtRes.text();
return {
jobDone: true,
jobId: res.JobId,
result: {
srtContent: srtText,
},
};
},
},
}
});