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として利用できる状態へ仕上げる工程は別です。
まずは現在の実装が何を生成しているかを正確に説明し、そのうえで対応範囲を広げていく方針です。