0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GIFからLive Photo用素材を作る際の実装メモ:フレーム合成・WebCodecs・コンテナ形式の注意点

0
Posted at

GIFからLive Photo用素材を作る際の実装メモ:フレーム合成・WebCodecs・コンテナ形式の注意点

ブラウザ上でGIFをデコードし、動画とカバー画像を書き出す機能を実装しています。

きっかけは、GIFをiPhoneのLive Photoとして利用するための素材を、ブラウザ内で用意したかったことです。

ただし、現在の実装が行っているのは、GIFから動画とJPEG画像を生成するところまでです。AppleのLive Photoとして直接保存できるファイルを生成するものではありません。

この記事では、実装コードをもとに、次の点を整理します。

  • GIFをフレームごとに合成するときのdisposal処理
  • フレームごとの表示時間をWebCodecsへ渡す方法
  • エンコードとコンテナへの格納の違い
  • 現在の実装に残っている制限と改善点

実装の構成

主に使用しているライブラリとAPIは、次のとおりです。

処理 使用しているもの
GIFの読み込み omggif
フレーム合成 OffscreenCanvas、RGBAバッファ
フレームの保持 ImageBitmap
動画エンコード WebCodecsのVideoEncoder
コンテナへの格納 mp4-muxer
カバー画像の生成 OffscreenCanvas.convertToBlob()
ファイルのまとめ出力 JSZip
GIFファイル
  ↓
フレームのデコードと合成
  ↓
ImageBitmap[] + フレームごとの表示時間
  ↓
VideoFrameへ変換
  ↓
VideoEncoderでH.264へエンコード
  ↓
mp4-muxerでMP4コンテナへ格納
  ↓
JPEGカバー画像とまとめてZIP出力

WebCodecsによるエンコードと、MP4などのコンテナに格納する処理は別です。VideoEncoderの出力を、そのまま完成した動画ファイルとして扱うわけではありません。

1. GIFの各フレームは、独立した完成画像とは限らない

今回の実装では、合成用のRGBAバッファを用意し、前のフレームの状態を引き継ぎながら描画しています。

以下は処理の順序を示した抜粋です。disposal処理の本体などは省略しています。

const frameData = renderCtx.createImageData(width, height)

for (let i = 0; i < numFrames; i++) {
  // 前フレームのdisposalを適用する
  // 必要なら復元用のスナップショットを取得する

  reader.decodeAndBlitFrameRGBA(i, frameData.data)
  renderCtx.putImageData(frameData, 0, 0)

  // 合成済みのキャンバスを呼び出し側へ渡す
}

重要なのは、現在のフレームを描く前に、前のフレームのdisposalを処理する点です。この処理を省くと、前のフレームの画素が残り、残像のような表示になる場合があります。

disposalに応じてバッファを戻す

disposal 現在の実装での扱い
0 / 1 合成バッファを維持する
2 前フレームの矩形領域のアルファ値を0にする
3 描画前に保存したバッファへ戻す

disposal === 3に対応するため、現在のフレームを描く前の状態を保存しています。

if (frameInfo.disposal === 3) {
  restoreSnapshot = new Uint8ClampedArray(frameData.data)
}

次のフレームへ進む際、前フレームのdisposalが3なら復元します。

if (prev.disposal === 3 && restoreSnapshot) {
  frameData.data.set(restoreSnapshot)
}

なお、現在のdisposal === 2の実装は「対象領域を透明にする」処理です。背景色を含むGIF全般への対応を、この処理だけで完了したとは考えていません。透明背景・非透明背景の両方で検証する必要があります。

2. フレームを間引いても、合成処理は飛ばさない

デコーダーには、出力フレームを間引くためのframeSkip引数も用意しています。

不要なフレームをデコード前に飛ばすと、後続フレームの合成に必要な状態まで失われる可能性があります。そのため、合成は全フレームに対して行い、ImageBitmapとして保持するかどうかだけを選択しています。

const meta = await decodeGifCore(
  file,
  async ({ canvas, index, delay }) => {
    if (
      frameSkip > 0 &&
      index % (frameSkip + 1) !== 0
    ) {
      return
    }

    frames.push(await createImageBitmap(canvas))
    delays.push(delay)
  }
)

ただし、これだけでは時間情報の処理が十分ではありません。

現在のコードでは、除外したフレームの表示時間もdelaysから抜けるため、間引いた結果をそのまま動画化すると、再生時間が短くなります。

動画出力で間引きを使う場合は、除外した時間を残すフレームへ加算するなど、時間情報も再構成する必要があります。

今回の動画変換では、decodeGif(file)をデフォルト引数で呼び出しているため、フレームの間引きは行っていません。

3. フレームごとの表示時間からタイムスタンプを作る

GIFのフレームは、すべて同じ表示時間とは限りません。

今回の実装では、omggifから取得したdelayをミリ秒へ変換し、WebCodecsへ渡す際にマイクロ秒へ変換しています。

const delayMs = frameInfo.delay * 10
const durationUs = delayMs * 1000

各フレームの開始時刻は、表示時間の累積値から作ります。

let pts = 0

for (let i = 0; i < frames.length; i++) {
  const duration = delays[i] * 1000

  const frame = new VideoFrame(frames[i], {
    timestamp: pts,
    duration,
  })

  encoder.encode(frame, {
    keyFrame: i % 30 === 0,
  })

  frame.close()
  pts += duration
}

固定フレームレートへ置き換えるのではなく、元の表示時間を使って時間軸を組み立てる方針です。

ただし、delayが0、または極端に短いGIFをどう扱うかは別途決める必要があります。現在のコードでは値をそのまま使用しているため、入力値の正規化は改善項目です。

4. 奇数サイズのフレームをどう扱うか

現在の実装では、エンコード用の幅と高さを偶数へ切り上げています。

const width = rawW + (rawW % 2)
const height = rawH + (rawH % 2)

ただし、現在の描画処理は次のようになっています。

ctx.drawImage(frame, 0, 0, width, height)

これは余白を追加するのではなく、画像を拡大する処理です。

元の構図や画素の対応を維持したい場合は、偶数サイズのキャンバスを用意したうえで、元サイズのまま描画する方法も考えられます。

const canvas = new OffscreenCanvas(width, height)
const ctx = canvas.getContext('2d')!

ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
ctx.drawImage(frame, 0, 0)

これは改善案です。背景色は白に固定するのではなく、出力用途に応じて選べる設計も考えられます。

5. MP4の拡張子をMOVに変えても、MOV変換にはならない

現在のコードでは、mp4-muxerで生成したバッファに対して、次のMIMEタイプを指定しています。

const blob = new Blob([target.buffer], {
  type: 'video/quicktime',
})

さらに、ZIP内では.movという拡張子を付けています。

しかし、mp4-muxerはMP4コンテナを生成するライブラリです。BlobのMIMEタイプやファイル名を変更しても、それだけでコンテナ形式が変換されるわけではありません。

現在のmuxerを使い続けるなら、出力の実体に合わせて、次のように扱うのが自然です。

const videoBlob = new Blob([target.buffer], {
  type: 'video/mp4',
})

zip.file(`${basename}.mp4`, videoBlob)

MOVコンテナが本当に必要なら、ファイル名ではなく、muxerの選択と出力構造から見直す必要があります。

また、MOVへ格納できたとしても、それだけでAppleのLive Photoを生成したことにはなりません。今回のコードには、静止画と動画をLive Photoとして関連付けて保存する処理は含まれていません。

6. 「無限ループ」ではなく、現在は3回繰り返している

現在のコードでは、ループ指定時にフレーム列を3回分エンコードしています。

const repeatCount = loop ? 3 : 1

生成されるのは「3回繰り返す有限長の動画」です。無限に再生する設定を動画へ付けているわけではありません。

実装に合わせるなら、UIの表記も次のようにするべきです。

1回再生
3回繰り返し

再生側でループさせることと、同じ内容を複数回分エンコードすることは、区別して扱う必要があります。

7. ブラウザ対応はエンコーダー設定まで確認する

現在のコードでは、コーデックを選択したあと、直接configure()を呼び出しています。

encoder.configure({
  codec,
  width,
  height,
  bitrate: 2_000_000,
})

改善するなら、同じ設定をVideoEncoder.isConfigSupported()へ渡し、対応状況を事前確認します。

以下はVideoEncoderを利用できる環境での設定チェック例です。API自体の存在確認も別途必要です。

const config: VideoEncoderConfig = {
  codec,
  width,
  height,
  bitrate: 2_000_000,
}

const support =
  await VideoEncoder.isConfigSupported(config)

if (!support.supported) {
  throw new Error(
    'この環境では指定した動画設定を利用できません。'
  )
}

encoder.configure(config)

VideoEncoderが存在するかどうかだけでは、指定したコーデックや解像度を使えるかまでは判断できません。ブラウザ名だけで一律に扱うのではなく、実際に使用する設定を確認する方針です。

8. 全フレーム保持とエンコードキューに注意する

現在の実装では、GIFの全フレームをImageBitmap[]として保持しています。さらに、変換ループ内で全フレームを続けてencode()へ渡しています。

この構成は処理の流れを理解しやすい一方、サイズやフレーム数の大きいGIFでは、メモリ使用量を抑える工夫が必要です。

WebCodecsのencode()は、エンコードの完了を待つのではなく、処理をキューへ追加します。待機中の要求数はencodeQueueSizeで確認できます。

改善案として、次の項目を考えています。

  • 入力サイズとフレーム数に上限を設ける
  • デコードからエンコードまでを逐次処理へ近づける
  • エンコードキューの増加に応じて投入を待つ
  • 例外時も含めてImageBitmapを解放する
  • 処理終了後にVideoEncoderを閉じる

OffscreenCanvasを使っていても、それだけで処理がWeb Worker上へ移るわけではありません。UIの応答性を改善する場合は、処理の実行場所も別途見直す必要があります。

カバー画像とZIP出力

カバー画像は、最初の合成済みフレームから生成しています。

async function extractCoverJpg(
  frame: ImageBitmap
): Promise<Blob> {
  const canvas =
    new OffscreenCanvas(frame.width, frame.height)

  const ctx = canvas.getContext('2d')!
  ctx.drawImage(frame, 0, 0)

  return canvas.convertToBlob({
    type: 'image/jpeg',
    quality: 0.92,
  })
}

動画とカバー画像を、それぞれZIPへ追加します。以下は、前述のコンテナ表記の修正を反映した例です。

const zip = new JSZip()

zip.file(`${basename}.mp4`, videoBlob)
zip.file(`${basename}-cover.jpg`, coverBlob)

const zipBlob = await zip.generateAsync({
  type: 'blob',
})

ここで生成するZIPは、動画と静止画をまとめて受け取るためのものです。Live Photo専用のパッケージ形式ではありません。

開発中のツール

今回の実装は、以下のツールで使用しています。

BulkPicTools:GIFからLive Photo変換

開発・運営は筆者が行っています。

現段階では、Live Photoを直接生成する処理ではなく、その前段の素材生成として捉えています。ファイル形式の表記や繰り返し設定など、実装と説明を一致させることも改善対象です。

まとめ

GIFから動画を書き出す処理では、単に各フレームをエンコーダーへ渡すだけでなく、次の点を整理する必要がありました。

  • disposalを考慮して表示用フレームを合成する
  • フレームを間引く場合も、合成の連続性を維持する
  • 表示時間からタイムスタンプを組み立てる
  • 画像のリサイズと余白追加を区別する
  • エンコード形式、コンテナ形式、拡張子を混同しない
  • 有限回の繰り返しと無限ループを区別する
  • ブラウザ対応とリソース解放を確認する

特に、GIFから動画を生成する工程と、AppleのLive Photoとして利用できる状態へ仕上げる工程は別です。

まずは現在の実装が何を生成しているかを正確に説明し、そのうえで対応範囲を広げていく方針です。

技術資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?