視頻剪輯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, });
各標記片段獨立導出
將選中標記片段分別導出為多個獨立視頻,單擊導出為視頻片段后,彈窗引導用戶設置多個獨立視頻的名稱、存儲地址、格式、分辨率、碼率等參數??蓮陀?b data-tag="uicontrol" id="uicontrol-guc-jsq-bdf" class="uicontrol">導出視頻彈窗。
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) }), ); }, ... })
片段合成導出
將選中片段按順序前后連接導出為一個新視頻,點擊后服務端設置默認值提交合成作業或打開合成設置彈窗,單擊片段合成導出后,彈窗引導用戶設置視頻的名稱、存儲地址、格式、分辨率、碼率等參數??蓮陀?b data-tag="uicontrol" id="uicontrol-56r-8hi-vzh" class="uicontrol">導出視頻彈窗。
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", {
// http://bestwisewords.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', { // http://bestwisewords.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', { // http://bestwisewords.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,
},
};
},
},
}
});