1. 程式人生 > 實用技巧 >羚瓏視訊編輯器開發總結

羚瓏視訊編輯器開發總結

作者:凹凸曼-大力士

專案背景

羚瓏平臺在靜態類的設計中,已經取得了相應的成績。在這個基礎上結合當前大環境,我們認為可以去做一些動態類的設計,將動畫和音效轉化為可儲存,可移植,可複用的資料。從而使用者進行創作的時候,可以通過相對很簡單的方式去使用這些高品質的動畫和效果。

視訊編輯器解決了什麼問題?

視訊編輯器的主要作用是使用者可以通過操作靜態的PSD從而得到我們想要的動態設計效果。對比AE等複雜的視訊編輯軟體,學習成本大大降低,且動效的可複用性、移植性等也減輕了使用者的工作量。

以下為設計效果:

開發實錄

如何讓你的靜態PSD"動"起來?

參考 AE 的製作動畫的過程,首先會預設劇本和分鏡,其次規劃好分鏡中的鏡頭如何運動,角色如何運動,以及處理和規劃素材。我們可以提煉出幾個關鍵點:多場景、鏡頭移動(即場景整體的動效)、規劃素材(圖層內容出現時刻及時間長短靈活可控)
視訊編輯器操作主要涉及功能點如下:

  • 多場景的切換與轉場效果的融合,使視訊效果更加生動靈活;
  • 場景動效以及動效引數的設定,減少了同類型動效的開發(如位移動效合併為一個),也打開了設計師對動效使用的想象力,收穫額外的視訊效果;
  • 圖層操作,調整出現時刻及持續時間;

編輯器介面如下圖:

狀態管理

視訊編輯器的實現主要分為 5 個部分,視訊預覽區、動效新增區、引數編輯區、圖層操作區、場景操作區,如下圖其他部分的每一個操作都會對映到視訊預覽區,且各個部分資料共享。除此之外,編輯器的每一步操作都需要被”記住“,便於編輯的人回退、還原其操作。

經分析會涉及到以下場景,如:

  • 預覽區元件的狀態需要共享
  • 其他操作區的變動會改變預覽區元件的狀態
  • 元件狀態都需要可撤銷/還原

我們可以採用 redux 集中管理狀態以減少元件之間的資料流傳遞;對於撤銷還原功能,我們可以採用 redux-undo,根據現有的 reducer 和配置物件,增強現有其撤消還原功能。

import ReduxUndo from 'redux-undo'
//定義原有的 reducer
const editReducer = (state = null, action) => {
  switch (action.type) {
    case VIDEO_INIT: {
       const { templates } = action.payload
       return { templates }
    }
    case VIDEO_TPL_CLEAR: {
      return {}
    }
}

//通過 ReduxUndo 增強 reducer 的可撤銷功能
export const undoEditReducer = ReduxUndo(editReducer, {
  initTypes: [VIDEO_TPL_CLEAR],
  filter: function filterActions (action, currentState, previousHistory) {
    const { isUndoIgnore = false } = action
    return !isUndoIgnore
  },
  groupBy: groupByActionTypes([SOME_ACTION]),
  /*
  自定義分組
  groupBy:(action, currentState, previousHistory) => {
    
  },
  */
})

引數說明

  • initTypes:歷史記錄將根據初始化操作型別進行設定(重置)
  • filter:過濾器, 可以幫助過濾掉不想在撤消/重做歷史中包含的操作;
  • groupBy:可以通過預設的 groupByActionTypes 方法將動作組合為單個撤消/重做步驟。也可以實現自定義分組行為,如果返回值不為 null,則新狀態將按該返回值分組。如果下一個狀態與上一個狀態歸為同一組,則這兩個狀態將在一個步驟中歸為一組;如果返回值為 null,則 redux-undo 不會將下一個狀態與前一個狀態分組。

使用 store.dispatch() 和 Undo/Redo Actions 對你的狀態執行撤消/重做操作

import { ActionCreators } from 'redux-undo'
export const undo = () => (dispatch, getState) => {
  dispatch(ActionCreators.undo())
}
export const redo = () => (dispatch, getState) => {
  dispatch(ActionCreators.redo())
}
export const recovery = () => (dispatch, getState) => {
  dispatch(ActionCreators.jumpToPast(0))
  dispatch(ActionCreators.clearHistory())
}

總結

對於狀態管理,首先我們可以從以下幾點考慮是否需要引入redux、mobx等工具:

  • 狀態是否被多個元件或者跨頁面共享;
  • 元件狀態需要跨越生命週期;
  • 狀態需要如持久化,可恢復/撤銷等操作。
    在使用redux管理狀態時,避免將所有狀態抽離至redux store中,如
  • 元件的私有狀態;
  • 元件狀態傳遞層級較少;
  • 當元件被unmount後可以銷燬的資料等
    原則上是能放在元件內部就放在元件內部。其次為了狀態的可讀性和可操作性,在狀態結構設計前,需要理清楚各個資料物件的關係,平衡資料獲取及操作複雜度,推薦扁平化資料結構以減少巢狀和資料冗餘。

圖層互動

在使用編輯器的過程中,圖層的互動操作是最多最頻繁的,我們參考了常用的客戶端視訊編輯軟體 AE、Final Cut 的互動,儘可能在 Web 上提供使用者操作的便利性及圖層視覺化,具體效果如下:

梳理圖層操作需求,主要包含:

  • 圖層軌道需要伸縮(調整圖層持續時間)
  • 圖層上的動效軌道可以單獨伸縮(調整動效持續時間)
  • 圖層軌道需要左右移動,且動效軌道跟隨移動(調整出現的時刻)
  • 動效軌道可以單獨左右移動(調整動效出現的時刻)
  • 不同圖層軌道可以上下調整順序,動效軌道跟隨圖層軌道移動(調整圖層順序)
  • 拖動時顯示不同的外觀

初始的時候首先考慮到需要移動圖層順序,我們基於 react-sortable-hoc 實現了基本的圖層順序拖曳移動 , 但是對於圖層的拉伸、左右拖動處理需要自定義滑鼠事件進行處理,並需要自定義計算控制圖層的移動,而且最初沒有考慮到拖動過程中拖動源的外觀需要調整,最終,我們放棄這種實現。我們需要一個可定製化程度更高的拖曳元件,經過一番比較後,我們最終選定了 react-dnd 拖拽元件,檢視其官方說明:

可幫助您構建複雜的拖放介面,同時保持元件的分離;且適用於拖動時在應用程式的不同部分之間傳輸資料,更完美的是元件可以響應拖放事件更改其外觀和應用程式狀態。

詳細說明下,react-dnd 建立在 HTML5 拖放 API 之上,它可以對已拖動的 DOM 節點進行螢幕快照,並直接將其用作“拖動預覽”, 簡化了我們在游標移動時進行繪製的操作。不過,HTML5 拖放 API 也有一些缺點。它在觸控式螢幕上不起作用,並且在 IE 上提供的自定義機會少於其他瀏覽器。這就是為什麼在 react-dnd 中以可插入方式實現 HTML5 拖放支援的原因,你也可以不使用它,根據觸控事件,滑鼠事件等自己來編寫其他實現。

下面,我們從外到內,介紹基本的實現。

場景層面

引入所需元件

import { DndProvider } from 'react-dnd'
import HTML5Backend from 'react-dnd-html5-backend'

將 DndProvider 放在整個場景的外層,設定 backend 為 HTML5Backend

<DndProvider backend={HTML5Backend}> 
  <TemplateViewer   // ----- 單個場景展示元件
    template={tpl}
    handleLayerSort={handleLayerSort}
    onLayerDrop={onLayerDrop}
    onLayerStretch={onLayerStretch}
  />
  <CustomDragLayer />  // --- 自定義拖拽預覽圖層
</DndProvider>

TemplateViewer 裡包含不同型別的圖層元件。每個圖層元件都提供一個純渲染元件的方法 renderLayerContent,大致結構如下:

export function renderLayerContent (layer) {
  return <div style={{...}}>...</div>
}

export default function XxxxLayerComponent (layer) {
  ...
  return <div>{renderLayerContent(layer)}</div>
}

CustomDragLayer 里根據當前拖拽的物件的元件型別,呼叫相應 renderLayerContent 繪製拖拽可視內容,以實現拖拽前後的檢視一致。

圖層層面


圖層可以上下拖動,也可以左右拖動,意味它本身即是拖拽源,也是放置的目標。

為了區分拖拽的目的,我們定義了兩個拖拽源

  const [{ isHorizontalDragging }, horizontalDrag, preview] = useDrag({
    item: {
      type: DragTypes.Horizontal,
    },
    collect: monitor => ({
      isHorizontalDragging: monitor.isDragging(),
    }),
  })
  const [{ isVerticalDragging }, verticalDrag, verticalPreview] = useDrag({
    item: {
      type: DragTypes.Vertical,
    },
    collect: monitor => ({
      isVerticalDragging: monitor.isDragging(),
    }),
  })

在放置處理中根據拖拽型別進行判斷處理

  const [, drop] = useDrop({
    accept: [DragTypes.Horizontal, DragTypes.Vertical],
    drop (item, monitor) {
      // 處理左右拖動
    },
    hover: throttle(item => {
      // 處理上下排序
    }, 300),
  })

將定義好的拖動源和放置目標關聯 DOM 。最外層 DIV 為圖層可拖動區域即放置目標,然後依次為水平拖拽層,垂直拖拽層

<div ref={drop}> // --- 放置目標 DOM
  <div ref={verticalPreview}>

    <div ref={horizontalDrag}> // --- 水平拖拽 DOM
    
      <div ref={verticalDrag}> // --- 垂直拖拽 DOM
        <Icon type='drag'/>
      </div>

      /* 圖層內容展示 */
      <div>{renderLayerContent(layer)}</div>
    </div>
  </div>
</div>

以上關於圖層上下拖動、左右拖動的大體框架已經實現。

上下拖動排序時,為了拖動過程中不展示拖動源只保留生成的螢幕快照,可以根據當前的拖動狀態將拖動源的透明度設定為 0

<div ref={drop}> // --- 放置目標 DOM
  <div
    ref={verticalPreview}
    style={{ opacity: isVerticalDragging ? 0 : 1 }}
  >
   ...
  </div>
</div>

水平拖動時,設定拖動源半透明,處理方式與上下拖動時同理。

圖層內

圖層內有兩個區域,下方區域可通過左右兩端的操作點進行拉伸,上方區域可以在下方區域的寬度內左右移動以及同樣通過左右兩端的操作點進行拉伸。
移動的實現方式前面已經介紹過就不重複了,針對拉伸的操作,我們封裝一個 Stretch 類來統一處理

function Stretch ({
  children,
  left,
  width,
  onStretchEnd,
  onStretchMove,
}) {
  function handleMouseDown (align) {
    // 計算偏移
  }

  return (
    <div>
      {children}
      <div
        className={classnames(styles.stretch, styles.stretchHead)}
        onMouseDown={handleMouseDown('head')}
      />
      <div
        className={classnames(styles.stretch, styles.stretchEnd)}
        onMouseDown={handleMouseDown('end')}
      />
    </div>
  )
}

將需要支援拉伸的區域作為作為 Stretch 的 children 傳遞進來

<div>
  <div>
    {motions.map((motion, i) => <Stretch key={i}>{/* 上方某個區域 */}</Stretch>)}
  </div>
  <div>
    <Stretch>{/* 下方區域 */}</Stretch>
  </div>
</div>

體驗優化

新增快捷鍵

整個編輯器內容比較的多,對頻繁的操作,我們可以保留常用快捷鍵的操作習慣。如空格播放、delete 刪除等等,該功能我們可以使用 react-hot-keys 實現。

首先引入該快捷鍵庫,然後指定繫結的快捷鍵,新增事件處理。

import Hotkeys from 'react-hot-keys'

<Hotkeys
  keyName='space'
  onKeyDown={(keyName, e) => {
    e.preventDefault()
    play()
  }}
/>

文字轉 SVG

另外圖層內容展示時有個小技巧,產品需求中文案圖層平鋪展示。可憐我最初竟然是通過文字長度以及軌道長度計算出文字展示次數,然後再放到 push 到節點中。經大佬改造後才明白可以將文字轉化為 SVG 然後以背景圖展示,真香!

<div
  className={styles.contentText}
  style={{
    backgroundImage: `url("data:image/svg+xml;utf8,
      <svg xmlns='http://www.w3.org/2000/svg' version='1.1'     width='${size(layer.text) * 12 + 15}px' height='35px'>
      <text x='10' y='22' fill='black' font-size='12'>
      ${layer.text}
      </text>
      </svg>")`,
      }}
/>

實現效果:

專案總結

本文講述了視訊編輯器中操作區主要模組的處理。關於狀態管理,我們主要需要明確引入管理工具的是否必要以及使用狀態管理工具後是否所有狀態都必須移入store中等等。另外對於複雜的圖層拖拽功能,要像剝洋蔥一樣,先層層拆解,從而層層完善其結構。
對專案而言,拿到需求後,我們從整體到區域性進行分析,優先確定整體的框架、核心功能的實現方式等,進而考慮如何提高使用者體驗度。需求分清主次,以便於我們排列優先順序從而開發提高效率。


歡迎關注凹凸實驗室部落格:aotu.io

或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章: