🖼️

iOS でも Document Picture-in-Picture したい!

に公開

どうも、uzimaru です。
Document Picture-in-Picture API という API がありますが、iOS などのモバイルブラウザでは動かないという制約があります。
そこで今回は、iOS でも動作する代替アプローチを思いついたので、その方法をまとめようと思います。

実際に動作するサンプルはこちらです(iOS Safari で試してみてください):

https://s2n8j2-5173.csb.app/

Document Picture-in-Picture とは

Document Picture-in-Picture API は、動画だけでなく任意の HTML コンテンツを Picture-in-Picture ウィンドウに表示できる比較的新しい API です。
通常の PiP が <video> 要素に限定されるのに対し、Document PiP では React コンポーネントなどの任意の DOM を小窓に表示できるため、カスタム UI を持った PiP ウィンドウを作成できます。

// Document PiP の基本的な使い方
const pipWindow = await documentPictureInPicture.requestWindow({
  width: 400,
  height: 300,
});

// 任意の DOM を表示できる
pipWindow.document.body.append(yourElement);

iOS での制約

しかし、この便利な Document PiP API は iOS Safari ではサポートされていません(2025年10月時点)。
iOS で利用できるのは従来の <video> 要素を使った PiP のみです。これでは動画しか表示できず、カスタム UI を持った PiP ウィンドウは作れません。

代替アプローチの概要

そこで、以下のような変換フローを使って iOS でも Document PiP 相当の機能を実現することにしました。

JSX → SVG → 画像 → Canvas → Video → PiP

具体的には、satori を使って JSX から SVG を生成し、それを画像に変換して Canvas に描画、Canvas のストリームを Video 要素のソースにすることで、<video> ベースの PiP API でも任意のコンテンツを表示できるようにします。

実装の全体像

この方法を選んだ理由は、以下の制約を満たす必要があったためです。

  • iOS Safari で動作すること
  • React コンポーネントを使って UI を書きたい
  • できるだけシンプルな実装で実現すること

Document PiP が使えない以上、iOS で利用可能な <video> 要素の PiP API を使うしかありません。しかし、通常の方法では動画コンテンツしか表示できません。
そこで、「任意のコンテンツを動画に変換する」というアプローチを取ることにしました。

なぜ satori なのか

HTML から画像を生成するライブラリはいくつかありますが、多くの場合 React コンポーネントを一度 HTML に変換してから描画する必要があります。
一方、satori は JSX を直接扱えるため、React コンポーネントを DOM に変換する手間が省けます。

また、以前に Cloudflare Workers で OGP 画像生成 をした際にも satori を使った経験があり、ブラウザ環境でも動作することを確認していたので、今回も採用しました。

主な利点は以下の通りです。

  • ブラウザ環境で動作する(Headless ブラウザ不要)
  • React の JSX を直接扱える(DOM への変換が不要)
  • SVG 経由なので高品質な出力が得られる
  • 軽量で依存関係が少ない

変換フローの詳細

それぞれのステップで以下の処理を行います。

  1. satori で JSX を SVG に変換: React コンポーネントを SVG 文字列として出力
  2. SVG から画像を生成: Data URL を使って Image オブジェクトを作成
  3. Canvas に描画: Image を Canvas に描画
  4. Canvas から MediaStream を取得: captureStream() で動画ストリームを取得
  5. Video 要素に設定: MediaStream を Video のソースに設定
  6. PiP で表示: Video 要素の requestPictureInPicture() を呼び出し

この一連の流れにより、iOS Safari でも任意の UI を PiP で表示できるようになります。

実装の詳細

実際の実装をステップごとに見ていきます。全体のコードは機能ごとにモジュール化しています。

1. satori で JSX を SVG に変換

まず、satori をインストールします。

npm install satori

satori を使って React 要素を SVG に変換する関数を作成します。

// satoriRenderer.ts
import satori, { type SatoriOptions } from 'satori'

export interface SatoriRendererOptions {
  width: number
  height: number
  fonts?: SatoriOptions['fonts']
}

export const renderToSVG = async (
  element: React.ReactNode,
  options: SatoriRendererOptions,
): Promise<string> => {
  const { width, height, fonts } = options

  // フォントが指定されていない場合はデフォルトをロード
  const fontsToUse = fonts || (await loadDefaultFonts())

  const svg = await satori(element, {
    width,
    height,
    fonts: fontsToUse,
  })

  return svg
}

フォントは必須なので、デフォルトフォントをロードする仕組みも用意します。

// fonts.ts
import type { SatoriOptions } from 'satori'

let cachedFonts: SatoriOptions['fonts'] | null = null

export const loadDefaultFonts = async (): Promise<SatoriOptions['fonts']> => {
  if (cachedFonts) {
    return cachedFonts
  }

  // バンドルしたフォントファイルを読み込む
  const fontUrl = new URL('../assets/fonts/NotoSansJP-Regular.otf', import.meta.url)
  const response = await fetch(fontUrl)
  const data = await response.arrayBuffer()

  cachedFonts = [
    {
      name: 'Noto Sans JP',
      data,
      weight: 400,
      style: 'normal',
    },
  ]

  return cachedFonts
}

今回はフォントファイルをプロジェクトに含めることで、動的な取得よりも安定性を重視しました。

2. SVG から画像を生成

SVG 文字列を Image 要素に変換する関数を作成します。

// satoriRenderer.ts
export const svgToImage = async (svg: string): Promise<HTMLImageElement> => {
  const img = new Image()
  const svgBlob = new Blob([svg], { type: 'image/svg+xml' })
  const url = URL.createObjectURL(svgBlob)

  try {
    img.src = url
    await img.decode() // 画像のデコードを待つ
    return img
  } finally {
    URL.revokeObjectURL(url) // メモリリーク防止
  }
}

img.decode() を使うことで、画像が完全にロードされるまで待機できます。

3. Canvas に画像を描画

Image を Canvas に描画する関数と、一連の処理をまとめた関数を作成します。

// satoriRenderer.ts
export const drawImageToCanvas = (
  image: HTMLImageElement,
  canvas: HTMLCanvasElement,
): void => {
  const ctx = canvas.getContext('2d')
  if (!ctx) {
    throw new Error('Failed to get 2D context from canvas')
  }

  ctx.drawImage(image, 0, 0, canvas.width, canvas.height)
}

export interface RenderToCanvasOptions extends SatoriRendererOptions {
  element: React.ReactNode
  canvas: HTMLCanvasElement
}

// JSX から Canvas への一連の処理をまとめた関数
export const renderToCanvas = async (
  options: RenderToCanvasOptions,
): Promise<void> => {
  const { element, canvas, ...satoriOptions } = options

  // Canvas のサイズを設定
  canvas.width = satoriOptions.width
  canvas.height = satoriOptions.height

  // JSX → SVG → Image → Canvas
  const svg = await renderToSVG(element, satoriOptions)
  const image = await svgToImage(svg)
  drawImageToCanvas(image, canvas)
}

4. React Hooks で PiP を実装

Canvas と Video の管理、PiP の制御を React Hooks でまとめます。

// hooks.ts
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { SatoriOptions } from 'satori'
import { renderToCanvas } from './satoriRenderer'

export interface UsePinPOptions {
  element: ReactNode
  width?: number
  height?: number
  fonts?: SatoriOptions['fonts']
  onEnter?: () => void
  onLeave?: () => void
}

export const usePinP = ({
  element,
  width = 640,
  height = 480,
  fonts,
  onEnter,
  onLeave,
}: UsePinPOptions) => {
  const videoRef = useRef<HTMLVideoElement | null>(null)
  const canvasRef = useRef<HTMLCanvasElement | null>(null)
  const streamRef = useRef<MediaStream | null>(null)

  const [isPiPActive, setIsPiPActive] = useState(false)
  const [isSupported] = useState(
    () => 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled,
  )

  // Video と Canvas を初期化
  useEffect(() => {
    if (!videoRef.current) {
      const video = document.createElement('video')
      video.muted = true
      video.playsInline = true
      video.autoplay = false
      // 画面上には表示しないが DOM には追加する必要がある
      video.style.position = 'absolute'
      video.style.left = '0'
      video.style.top = '0'
      video.style.width = '1px'  // 最小サイズにする
      video.style.height = '1px' // 最小サイズにする
      video.style.opacity = '0'
      video.style.pointerEvents = 'none'
      video.preload = 'auto' // 事前ロードして準備しておく
      document.body.appendChild(video)
      videoRef.current = video
    }

    if (!canvasRef.current) {
      const canvas = document.createElement('canvas')
      canvas.width = width
      canvas.height = height
      canvas.style.display = 'none'
      document.body.appendChild(canvas)
      canvasRef.current = canvas
    }

    // Canvas から MediaStream を取得
    const canvas = canvasRef.current
    const video = videoRef.current
    if (canvas && video && !streamRef.current) {
      const stream = canvas.captureStream()
      streamRef.current = stream
      video.srcObject = stream
      video.load()
    }

    return () => {
      // クリーンアップ
      if (videoRef.current) {
        document.body.removeChild(videoRef.current)
        videoRef.current = null
      }
      if (canvasRef.current) {
        document.body.removeChild(canvasRef.current)
        canvasRef.current = null
      }
      if (streamRef.current) {
        streamRef.current.getTracks().forEach(track => track.stop())
        streamRef.current = null
      }
    }
  }, [width, height])

  // element が変更されたら Canvas を再描画
  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return

    let cancelled = false

    const render = async () => {
      if (cancelled) return

      try {
        await renderToCanvas({
          element,
          canvas,
          width,
          height,
          fonts,
        })
      } catch (error) {
        console.error('Failed to render to canvas:', error)
      }
    }

    render()

    return () => {
      cancelled = true
    }
  }, [element, width, height, fonts])

  // PiP イベントリスナー
  useEffect(() => {
    const video = videoRef.current
    if (!video) return

    const handleEnterPiP = () => {
      setIsPiPActive(true)
      onEnter?.()
    }

    const handleLeavePiP = () => {
      setIsPiPActive(false)
      onLeave?.()
    }

    video.addEventListener('enterpictureinpicture', handleEnterPiP)
    video.addEventListener('leavepictureinpicture', handleLeavePiP)

    return () => {
      video.removeEventListener('enterpictureinpicture', handleEnterPiP)
      video.removeEventListener('leavepictureinpicture', handleLeavePiP)
    }
  }, [onEnter, onLeave])

  const enter = useCallback(async () => {
    if (!isSupported) {
      throw new Error('Picture-in-Picture is not supported')
    }

    const video = videoRef.current
    if (!video) {
      throw new Error('Video element not initialized')
    }

    // iOS では play() と requestPictureInPicture() を同期的に呼ぶ必要がある
    // await を使うとユーザージェスチャーのコンテキストが失われる
    video.play().catch((error) => {
      console.error('Failed to play video:', error)
    })

    video.requestPictureInPicture().catch((error) => {
      console.error('Failed to enter PiP:', error)
      throw error
    })
  }, [isSupported])

  const exit = useCallback(async () => {
    if (document.pictureInPictureElement) {
      await document.exitPictureInPicture()
    }
  }, [])

  const toggle = useCallback(async () => {
    if (isPiPActive) {
      exit()
    }
    enter()
  }, [isPiPActive, enter, exit])

  return {
    isSupported,
    active: isPiPActive,
    toggle,
    enter,
    exit,
  }
}

使い方

実装した Hooks は以下のように使用できます。

import { usePinP } from './pip'

function App() {
  const { toggle, active, isSupported } = usePinP({
    element: (
      <div style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        backgroundColor: '#1a1a1a',
        color: 'white',
        fontSize: 60,
      }}>
        Hello, PiP!
      </div>
    ),
    width: 640,
    height: 480,
  })

  return (
    <button onClick={toggle} disabled={!isSupported}>
      {active ? 'Close PiP' : 'Open PiP'}
    </button>
  )
}

ハマったポイント・注意点

実装する中でいくつかハマったポイントがあったので、共有します。

iOS Safari での制約

iOS Safari で PiP を動作させるには、いくつかの重要な制約があります。

1. ユーザージェスチャーのコンテキストを失わない

iOS では play()requestPictureInPicture() をユーザーインタラクション(クリックなど)の同期的なコンテキスト内で呼び出す必要があります。

// ❌ これは iOS で動かない
const enter = async () => {
  await video.play()  // await するとユーザージェスチャーのコンテキストが失われる
  await video.requestPictureInPicture()
}

// ✅ これなら動く
const enter = () => {
  video.play().catch(error => console.error(error))
  video.requestPictureInPicture().catch(error => console.error(error))
}

await を使うと非同期処理によってユーザージェスチャーのコンテキストが失われ、PiP の起動に失敗します。

2. Video 要素を DOM に追加する必要がある

Video 要素は画面上に表示する必要はありませんが、DOM に追加されている必要があります。そのため、1px × 1px の最小サイズにして、opacity: 0 で非表示にしています。

video.style.width = '1px'
video.style.height = '1px'
video.style.opacity = '0'

完全に display: none にすると PiP が動作しないため、この方法を取っています。
また、left: -9999px のように画面外に配置する方法も試しましたが、これも PiP が起動しませんでした。Video 要素は描画領域内に存在する必要があるようです。

まとめ

iOS Safari で Document Picture-in-Picture API が使えない問題に対して、satori を使った代替実装を紹介しました。

この方法のメリット

  • iOS Safari で動作する: 従来の Video PiP API を使うため、iOS でも問題なく動作します
  • React コンポーネントを使える: satori のおかげで JSX をそのまま使えます
  • ブラウザ環境で完結: Headless ブラウザなどのサーバー側の処理が不要です
  • 高品質な描画: SVG 経由なので、拡大しても綺麗に表示されます

この方法のデメリット

  • インタラクションができない: 実態は video 要素なので、ボタンなどの UI 操作はできません
  • リアルタイム更新のコスト: 内容を更新するたびに JSX → SVG → Image → Canvas の変換が必要です
  • satori の制約: CSS のサブセットしか使えず、すべての React コンポーネントが描画できるわけではありません。また、 div には display が必須などの制約もあります。
  • フォントの準備が必要: フォントデータを事前に用意するか、動的に取得する必要があります

今後の改善案

現状の実装でも基本的な用途には十分ですが、以下のような改善が考えられます。

Document PiP との併用

環境によって使い分けることで、より良い UX を提供できます。

const useAdaptivePiP = () => {
  const hasDocumentPiP = 'documentPictureInPicture' in window

  if (hasDocumentPiP) {
    // Document PiP を使う(インタラクション可能)
    return useDocumentPiP()
  } else {
    // Canvas ベースの PiP を使う(iOS 対応)
    return usePinP()
  }
}

この方法で、デスクトップでは Document PiP、iOS では Canvas ベースの PiP という使い分けができます。


今回の実装は、iOS で PiP を使いたいという要望から思いつきで試してみたら意外とうまく動いたので記事にしました。
プロダクション環境でどこまで安定的に使えるかは検証が必要ですが、特定のユースケース(情報表示や通知など)では十分実用的だと思います。

将来的に iOS Safari でも Document Picture-in-Picture API が正式にサポートされることを楽しみに待ちましょう!

参考リンク

Discussion