文档

扩展功能

更新时间:

视频剪辑Web端Demo只包含了视频剪辑Web SDK最基本的功能,您可以根据实际需求在此基础上扩展。通过阅读本文,您可以了解Web SDK的扩展功能示例。

目录

动态获取视频剪辑Web SDK的版本号

自定义字幕默认文字

自定义按钮文案

修改默认预览画布比例

主动获取Timeline数据

自定义返回按钮

自定义Logo

自定义媒资导入界面

自定义合成导出界面

智能生成字幕

媒资标记

拆条及导出

智能生成配音

自定义字体列表

分离视频音轨

接入数字人

自定义专属人声

扩展功能示例

在扩展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 {
      // 满足要求,省略后续对服务端发起合成请求的步骤
    }
  },
});

智能生成字幕

默认视频剪辑界面没有智能生成字幕按钮,可以通过传入参数submitASRJob实现。

// 注意,Web SDK 本身并不提供 request 这个方法,这里仅作为示例,您可以使用您喜欢的网络请求库,如 axios 等

const submitASRJob = async (mediaId, startTime, duration) => {
  const res = await request('SubmitASRJob', { // https://help.aliyun.com/document_detail/203425.html
    inputFile: mediaId,
    startTime,
    duration
  });

  if (res.code === '200') {
    const jobId = res.data.JobId;
    
    const interval = 10000; // 轮询的时间间隔,接入方可以自定义
    const totalTimes = 10; // 轮询次数,接入方可以自定义
    let result = {};
    for (let i = 0; i < totalTimes; i++) {
      await new Promise(resolve => {
        window.setTimeout(resolve, interval);
      });
      
      // 获取智能任务结果
      result = await request('GetSmartHandleJob', { // https://help.aliyun.com/document_detail/203429.html
        JobId: jobId
      });
      if (result.code !== '200') break; // 任务失败,结束轮询
      const state = res.data.State;
      if (state !== 'Creating' && state !== 'Executing') break;
    }
    
    if (result.code === '200' && result.data.State === 'Finished') {
      return JSON.parse(result.data.Output);
    } else {
      throw new Error('智能识别字幕失败')
    }
  } else {
    throw new Error(res.message);
  }
};

媒资标记

  • 传入媒资标记

    1. 导入媒资时传入媒资标记

      在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;
      }
    2. 提交合成时传入媒资标记

      将媒资标记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);
    },
    ...
    
    })
  • 导出视频

    将整个时间轨的全部素材按图层结构、时间顺序及指定效果导出为一个新视频,详情请参见自定义合成导出界面

智能生成配音

默认视频剪辑界面没有智能配音按钮,可以通过传入参数submitAudioProduceJob实现。

// 轮询
function poll(fn, fnCondition, ms, timeout = 1000 * 60 * 30) {
  const startTime = moment();
  await new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
  let result = await fn();
  while (fnCondition(result)) {
    const currentTime = moment();
    if (currentTime - startTime > timeout - ms) {
      return {
        timeout: true,
        result,
      };
    }
    // eslint-disable-next-line no-await-in-loop
    await new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
    // eslint-disable-next-line no-await-in-loop
    result = await fn();
  }
  return {
    timeout: false,
    result,
  };
}
// 注意,Web SDK 本身并不提供 request 这个方法,这里仅作为示例,您可以使用您喜欢的网络请求库,如 axios 等
const submitAudioProduceJob = async (text, voice, voiceConfig = {}) => {
  // 智能生成配音会生成一个音频文件存放到接入方的 OSS 上,这里 bucket, path 和 filename 是一种命名的示例,接入方可以自定义
  const bucket = 'MyBucket';
  const path = 'autoProducedAudios/';
  const filename = `${text.slice(0, 10)}${Date.now()}`;

  // 1-提交智能配音任务
  const res1 = await request('SubmitAudioProduceJob', { // https://help.aliyun.com/document_detail/212273.html
    EditingConfig: JSON.stringify({
      voice,
      format: 'mp3',
      ...voiceConfig,
    }),
    InputConfig: text,
    OutputConfig: JSON.stringify({
      bucket,
      object: `${path}${filename}`
    })
  });

  if (res1.code !== '200') {
    throw new Error('暂未识别当前文字内容');
  }

  // 2-智能配音任务是否完成【轮询】
  const getJobStatus = () => {
    return request('GetSmartHandleJob', { // https://help.aliyun.com/document_detail/203429.html
      JobId: res1.data.JobId,
    });
  };
  const shouldContinueGetJobStatus = (res) => {
    if (res.code !== '200' || res.data.State === 'Finished') return false;
    return true;
  };
  const { result: res2 } = await poll(
    getJobStatus,
    shouldContinueGetJobStatus,
    2000,
    20000
  );

  // 3-智能配音任务完成则拉取生成的音频【轮询】
  if (res2.code === '200' && res2.data.State === 'Finished') {
    const mediaId = res2.data.Output;

    const getProducedAudioInfo = () => {
      return request('GetMediaInfo', {
        MediaId: mediaId,
      });
    };
    const shouldContinueGetProducedAudioInfo = (res) => {
      if (res.code !== '200') return false;
      if (res.data?.MediaInfo?.MediaBasicInfo?.Status === 'Normal') return false;
      return true;
    };
    const res3 = await poll(
      getProducedAudioInfo,
      shouldContinueGetProducedAudioInfo,
      5000,
      15000,
      );

    if (res3.timeout) {
      throw new Error('智能配音任务超时,请重新发起');
    } else {
      const result = await transMediaList([result.data.MediaInfo]); // transMediaList 同前文中的定义
      const newAudio = result[0];
      // 4-将新的音频素材与工程进行绑定
      await request('AddEditingProjectMaterials', {
        ProjectId: 'xxxxx',
        MaterialMaps: {
          audio: newAudio.mediaId,
        },
      });
      return newAudio;
    }
  } else {
    throw new Error(res2.data.ErrorMsg || '抱歉,暂未识别当前文字内容');
  }
};

自定义字体列表

默认视频剪辑支持的字体为阿里云官方字体,如下所示:

// 官方支持的字体列表,及中文对照表
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分钟。

如何生成代理音频

如果您的媒资存储在智能媒体服务中,可以使用媒体处理服务对视频进行音频转码,转码结束后,会在媒资详情页的视频地址页签下生成的代理音频。

001

说明

您可以通过以下方式对视频进行音频转码:

  • 媒资管理页面单击操作列的媒体处理,选择音频转码相关的转码模板或工作流。

  • 点播媒体处理任务管理页面中创建音频转码任务。

  • 上传音视频页面中媒体处理选择上传后,自动进行媒体处理,并选择音频转码相关的工作流。

如何为视频添加“该媒资含有代理音频”的标记

在导入媒资时,接口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,当任务完成时,请确保媒资库中遮罩视频和绿幕视频都已生成,每次轮询都会返回任务当前状态。

    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: {
           // 视频输出分辨率码率
            outputConfigs:[
              { width: 1080, height: 1920, bitrates: [3000,2000] },
              { width: 720, height: 1280, bitrates: [1500,1000] },
              { width: 1920, height: 1080, bitrates: [3000,2000] },
              { width: 1280, height: 720, bitrates: [1500,1000] },
            ],
            // 视频输出分辨率码率
    
            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,
                };
              }
            },
          }
    
    })

自定义专属人声

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
   ...
 })   
  • 本页导读 (1)