banner
jzman

jzman

Coding、思考、自觉。
github

AndroidネイティブコーデックインターフェースMediaCodecの詳細解説

PS:いくつかのアイデアは先に始めて、徐々に改善するのが良い選択です。

MediaCodec は Android のコーデックコンポーネントで、低レベルで提供されるコーデックにアクセスするために使用され、通常は MediaExtractor、MediaSync、MediaMuxer、MediaCrypto、MediaDrm、Image、Surface、AudioTrack と一緒に使用されます。MediaCodec はほぼ Android プレーヤーのハードデコードの標準ですが、具体的にソフトコーデックを使用するかハードコーデックを使用するかは、MediaCodec の設定に関連しています。以下のいくつかの側面から MediaCodec を紹介します。主な内容は以下の通りです。

  1. MediaCodec が処理するタイプ
  2. MediaCodec のコーディングプロセス
  3. MediaCodec のライフサイクル
  4. MediaCodec の作成
  5. MediaCodec の初期化
  6. MediaCodec のデータ処理方法
  7. アダプティブ再生のサポート
  8. MediaCodec の例外処理

MediaCodec が処理するタイプ#

MediaCodec は、圧縮データ(compressed data)、生音声データ(raw audio data)、生動画データ(raw video data)の 3 種類のデータタイプを処理することをサポートしています。これらの 3 種類のデータは ByteBuffer を使用して処理でき、後述するバッファに相当します。生動画データについては、Surface を使用してコーデックのパフォーマンスを向上させることができますが、生動画データにはアクセスできません。ただし、ImageReader を介して生動画フレームにアクセスし、Image を通じて対応する YUV データなどの他の情報を取得できます。

圧縮バッファ:デコーダーの入力バッファとエンコーダーの出力バッファには、MediaFormat の KEY_MIME に対応するタイプの圧縮データが含まれます。動画タイプの場合、通常は単一の圧縮動画フレームであり、音声データの場合、通常は数ミリ秒の音声を含むエンコードされた音声セグメントです。

生音声バッファ:生音声バッファには、PCM 音声データの全フレームが含まれています。これは、各チャネルがチャネル順にサンプルされたものです。各 PCM 音声サンプルは、16 ビットの符号付き整数または浮動小数点数(ネイティブバイト順)です。浮動小数点 PCM エンコードの生音声バッファを使用する場合は、次のように設定する必要があります。

mediaFormat.setInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_FLOAT);

MediaFormat 内の浮動小数点 PCM を確認する方法は次の通りです。

 static boolean isPcmFloat(MediaFormat format) {
  return format.getInteger(MediaFormat.KEY_PCM_ENCODING, AudioFormat.ENCODING_PCM_16BIT)
      == AudioFormat.ENCODING_PCM_FLOAT;
 }

16 ビットの符号付き整数音声データを含むバッファの 1 つのチャネルを抽出するには、次のコードを使用できます。

// バッファ PCM エンコーディングが 16 ビットであると仮定します。
short[] getSamplesForChannel(MediaCodec codec, int bufferId, int channelIx) {
  	ByteBuffer outputBuffer = codec.getOutputBuffer(bufferId);
  	MediaFormat format = codec.getOutputFormat(bufferId);
  	ShortBuffer samples = outputBuffer.order(ByteOrder.nativeOrder()).asShortBuffer();
  	int numChannels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT);
  	if (channelIx < 0 || channelIx >= numChannels) {
    	return null;
  	}
 	short[] res = new short[samples.remaining() / numChannels];
  	for (int i = 0; i < res.length; ++i) {
    	res[i] = samples.get(i * numChannels + channelIx);
  	}
  	return res;
}

生動画バッファ:ByteBuffer モードでは、動画バッファは MediaFormat の KEY_COLOR_FORMAT に設定された値に基づいてレイアウトされます。デバイスがサポートする色形式を取得するには、MediaCodecInfo に関連するメソッドを使用できます。動画コーデックは、次の 3 つの色形式をサポートしている可能性があります。

  • native raw video format:原始的な動画形式で、CodecCapabilities の COLOR_FormatSurface 定数でマークされ、入力または出力 Surface と一緒に使用できます。

  • flexible YUV buffers:柔軟な YUV バッファで、CodecCapabilities の COLOR_FormatYUV420Flexible 定数に対応する色形式で、getInput、OutputImage などを介して入力、出力 Surface および ByteBuffer モードと一緒に使用できます。

  • other specific formats:他の特定の形式:通常、ByteBuffer モードでのみこれらの形式がサポートされます。一部の色形式はベンダー固有であり、他はすべて CodecCapabilities に定義されています。

Android 5.1 以降、すべての動画コーデックは柔軟な YUV 4:2:0 バッファをサポートしています。MediaFormat#KEY_WIDTH と MediaFormat#KEY_HEIGHT キーは、動画フレームのサイズを指定します。ほとんどの場合、動画は動画フレームの一部を占めます。具体的には次のように示されます。

image

出力形式から生出力画像のクロッピング矩形を取得するには、次のキーを使用する必要があります。出力形式にこれらのキーが存在しない場合、動画は全体の動画フレームを占めます。MediaFormat#KEY_ROTATION を使用する前、つまり回転を設定する前に、以下の方法で動画フレームのサイズを計算できます。参考は次の通りです。

 MediaFormat format = decoder.getOutputFormat(…);
 int width = format.getInteger(MediaFormat.KEY_WIDTH);
 if (format.containsKey("crop-left") && format.containsKey("crop-right")) {
    width = format.getInteger("crop-right") + 1 - format.getInteger("crop-left");
 }
 int height = format.getInteger(MediaFormat.KEY_HEIGHT);
 if (format.containsKey("crop-top") && format.containsKey("crop-bottom")) {
    height = format.getInteger("crop-bottom") + 1 - format.getInteger("crop-top");
 }

MediaCodec のコーディングプロセス#

MediaCodec は最初に空の入力バッファを取得し、エンコードまたはデコードするデータを充填し、充填されたデータの入力バッファを MediaCodec に送信して処理します。データの処理が完了すると、この充填データの入力バッファは解放され、最後にエンコードまたはデコードされた出力バッファを取得し、使用後に出力バッファを解放します。コーディングプロセスのフローチャートは以下の通りです。

image

各ステージに対応する API は以下の通りです。

// 利用可能な入力バッファのインデックスを取得
public int dequeueInputBuffer (long timeoutUs)
// 入力バッファを取得
public ByteBuffer getInputBuffer(int index)
// データで満たされた inputBuffer をエンコードキューに送信
public final void queueInputBuffer(int index,int offset, int size, long presentationTimeUs, int flags)
// 成功裏にエンコードまたはデコードされた出力バッファのインデックスを取得
public final int dequeueOutputBuffer(BufferInfo info, long timeoutUs)
// 出力バッファを取得
public ByteBuffer getOutputBuffer(int index)
// 出力バッファを解放
public final void releaseOutputBuffer(int index, boolean render) 

MediaCodec のライフサイクル#

MediaCodec には、実行中(Executing)、停止中(Stopped)、解放中(Released)の 3 つの状態があります。実行中と停止中にはそれぞれ 3 つのサブ状態があります。実行中の 3 つのサブ状態は Flushed、Running、Stream-of-Stream であり、停止中の 3 つのサブ状態は Uninitialized、Configured、Error です。MediaCodec のライフサイクルのフローチャートは以下の通りです。

同期モードでのライフサイクル非同期モードでのライフサイクル
imageimage

上の図に示すように、3 つの状態の切り替えはすべて start、stop、reset、release などによってトリガーされます。MediaCodec のデータ処理方法によって、ライフサイクルは若干異なります。非同期モードでは、start の後すぐに Running サブ状態に入ります。Flushed サブ状態にある場合は、再度 start を呼び出して Running サブ状態に入る必要があります。以下は各サブ状態の切り替えに対応する重要な API です。

  • 停止状態(Stopped)
// MediaCodec を作成して Uninitialized サブ状態に入る
public static MediaCodec createByCodecName (String name)
public static MediaCodec createEncoderByType (String type)
public static MediaCodec createDecoderByType (String type)
// MediaCodec を設定して Configured サブ状態に入る。crypto と descrambler については後述します。
public void configure(MediaFormat format, Surface surface, MediaCrypto crypto, int flags)
public void configure(MediaFormat format, @Nullable Surface surface,int flags, MediaDescrambler descrambler)
// Error
// コーディングプロセス中にエラーが発生し、Error サブ状態に入る
  • 実行状態(Executing)
// start の後すぐに Flushed サブ状態に入る
public final void start()
// 最初の入力バッファがデキューされたときに Running サブ状態に入る
public int dequeueInputBuffer (long timeoutUs)
// 入力バッファとストリーム終了マークがキューに入ると、コーデックは End-of-Stream サブ状態に変わります。
// この時点で MediaCodec は他の入力バッファを受け付けませんが、出力バッファを生成します。
public void queueInputBuffer (int index, int offset, int size, long presentationTimeUs, int flags)
  • 解放状態(Released)
// コーディングが完了した後、MediaCodec を解放して Released 状態に入る
public void release ()

MediaCodec の作成#

前述の通り、MediaCodec を作成すると Uninitialized サブ状態に入ります。その作成方法は以下の通りです。

// MediaCodec を作成
public static MediaCodec createByCodecName (String name)
public static MediaCodec createEncoderByType (String type)
public static MediaCodec createDecoderByType (String type)

createByCodecName を使用する際は、MediaCodecList を利用してサポートされているコーデックを取得できます。以下は指定された MIME タイプのエンコーダを取得する方法です。

/**
 * 指定された MIME タイプのエンコーダをクエリします
 */
fun selectCodec(mimeType: String): MediaCodecInfo? {
    val mediaCodecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
    val codeInfos = mediaCodecList.codecInfos
    for (codeInfo in codeInfos) {
        if (!codeInfo.isEncoder) continue
        val types = codeInfo.supportedTypes
        for (type in types) {
            if (type.equals(mimeType, true)) {
                return codeInfo
            }
        }
    }
    return null
}

もちろん、MediaCodecList もコーデックを取得するためのメソッドを提供しています。以下の通りです。

// 指定された形式のエンコーダを取得
public String findEncoderForFormat (MediaFormat format)
// 指定された形式のデコーダを取得
public String findDecoderForFormat (MediaFormat format)

上記のメソッドのパラメータ MediaFormat 形式には、フレームレートの設定が含まれてはいけません。すでにフレームレートが設定されている場合は、それをクリアしてから使用する必要があります。

上記で MediaCodecList について触れましたが、MediaCodecList を使用すると、現在のデバイスがサポートしているすべてのコーデックを簡単に列挙できます。MediaCodec を作成する際には、現在の形式をサポートするコーデックを選択する必要があります。選択したコーデックは対応する MediaFormat をサポートしている必要があります。各コーデックは MediaCodecInfo オブジェクトにラップされており、これによりそのエンコーダの特性を確認できます。たとえば、ハードウェアアクセラレーションのサポート、ソフトデコードかハードデコードかなど、一般的なものは以下の通りです。

// ソフトデコードかどうか
public boolean isSoftwareOnly ()
// Android プラットフォームが提供する (false) かベンダーが提供する (true) コーデックか
public boolean isVendor ()
// ハードウェアアクセラレーションをサポートしているか
public boolean isHardwareAccelerated ()
// エンコーダかデコーダか
public boolean isEncoder ()
// 現在のコーデックがサポートする形式を取得
public String[] getSupportedTypes ()
// ...

ソフトデコードとハードデコードは、音声および動画開発において必ず把握しておくべきことです。MediaCodec を使用する際、すべてがハードデコードであるとは限りません。実際に使用するエンコーダによって、ハードデコードかソフトデコードかが決まります。一般的に、ベンダーが提供するコーデックはハードデコードコーデックであり、たとえば Qualcomm (qcom) などです。一方、システムが提供するものはソフトデコードコーデックであり、Android の文字が付いたコーデックなどです。以下は私の(MI 10 Pro)スマートフォンの一部のコーデックです。

// ハードデコードコーデック
OMX.qcom.video.encoder.heic
OMX.qcom.video.decoder.avc
OMX.qcom.video.decoder.avc.secure
OMX.qcom.video.decoder.mpeg2
OMX.google.gsm.decoder
OMX.qti.video.decoder.h263sw
c2.qti.avc.decoder
...
// ソフトデコードコーデック
c2.android.aac.decoder
c2.android.aac.decoder
c2.android.aac.encoder
c2.android.aac.encoder
c2.android.amrnb.decoder
c2.android.amrnb.decoder
...

MediaCodec の初期化#

MediaCodec を作成した後、Uninitialized サブ状態に入ります。この時点で、MediaFormat を指定するなどの設定を行う必要があります。非同期データ処理方式を使用する場合は、configure の前に MediaCodec.Callback を設定する必要があります。重要な API は以下の通りです。

// 1. MediaFormat
// MediaFormat を作成
public static final MediaFormat createVideoFormat(String mime,int width,int height)
// 機能を有効または無効にする。具体的には MediaCodeInfo.CodecCapabilities を参照
public void setFeatureEnabled(@NonNull String feature, boolean enabled)
// パラメータ設定
public final void setInteger(String name, int value)

// 2. setCallback
// 非同期データ処理方式を使用する場合は、configure の前に MediaCodec.Callback を設定する必要があります。
public void setCallback (MediaCodec.Callback cb)
public void setCallback (MediaCodec.Callback cb, Handler handler)

// 3. 設定
public void configure(MediaFormat format, Surface surface, MediaCrypto crypto, int flags)
public void configure(MediaFormat format, @Nullable Surface surface,int flags, MediaDescrambler descrambler)

上記の configure 設定にはいくつかのパラメータが含まれています。surface はデコーダーがレンダリングする Surface を示し、flags は現在のコーデックがエンコーダーとして使用されるかデコーダーとして使用されるかを指定します。crypto と descrambler は暗号化に関連しており、特定の VIP 動画にはデコードに特定のキーが必要です。ユーザーがログインして検証された後にのみ、動画コンテンツが解読されます。そうでなければ、視聴するために支払う必要がある動画がダウンロードされた後に自由に拡散されることになります。詳細については、音声および動画におけるデジタル著作権技術を参照してください。

また、AAC 音声や MPEG4、H.264、H.265 動画形式などの特定の形式には、MediaCodec の初期化に特定のデータが含まれています。これらの圧縮形式をデコード処理する際には、start の後、かつフレームデータ処理の前に、これらの特定のデータを MediaCodec に提出する必要があります。つまり、queueInputBuffer の呼び出しで BUFFER_FLAG_CODEC_CONFIG フラグを使用してこのようなデータをマークします。これらの特定のデータは、MediaFormat を使用して ByteBuffer の方式で設定することもできます。以下の通りです。

// csd-0、csd-1、csd-2 も同様
val bytes = byteArrayOf(0x00.toByte(), 0x01.toByte())
mediaFormat.setByteBuffer("csd-0", ByteBuffer.wrap(bytes))

csd-0、csd-1 などのキーは、MediaExtractor#getTrackFormat から取得した MediaFormat から取得できます。これらの特定のデータは、start 時に自動的に MediaCodec に提出されるため、直接提出する必要はありません。出力バッファまたは形式が変更される前に flush が呼び出された場合、提出された特定のデータは失われます。その場合、queueInputBuffer の呼び出しで BUFFER_FLAG_CODEC_CONFIG フラグを使用してこのようなデータをマークする必要があります。

Android は以下のコーデック専用のデータバッファを使用します。MediaMuxer トラックを正しく設定するためには、これらをトラック形式として設定する必要があります。各パラメータセットと(*)でマークされたコーデック専用データ部分は、「\ x00 \ x00 \ x00 \ x01」の開始コードで始まる必要があります。参考は以下の通りです。

image

エンコーダがこれらの情報を受け取ると、同様に BUFFER_FLAG_CODEC_CONFIG フラグが付けられた outputbuffer を出力します。この時点で、これらのデータは特定のデータであり、メディアデータではありません。

MediaCodec のデータ処理方法#

作成された各コーデックは、入力バッファのセットを維持します。データ処理には同期と非同期の 2 つの方法があります。API バージョンによって異なる場合があります。API 21、つまり Android 5.0 以降は、ByteBuffer の方法でデータを処理することが推奨されます。それ以前は、ByteBuffer 配列の方法でデータを処理することしかできませんでした。以下の通りです。

image

MediaCodec、つまりコーデックのデータ処理は、主に入力、出力バッファの取得、データをコーデックに提出、出力バッファを解放するというプロセスです。同期方式と非同期方式の違いは、入力バッファと出力バッファの重要な API にあります。以下の通りです。

// 入力バッファを取得(同期)
public int dequeueInputBuffer (long timeoutUs)
public ByteBuffer getInputBuffer (int index)
// 出力バッファを取得(同期)
public int dequeueOutputBuffer (MediaCodec.BufferInfo info, long timeoutUs)
public ByteBuffer getOutputBuffer (int index)
// 入力、出力バッファのインデックスは MediaCodec.Callback のコールバックから取得し、対応する入力、出力バッファを取得(非同期)
public void setCallback (MediaCodec.Callback cb)
public void setCallback (MediaCodec.Callback cb, Handler handler)
// データを提出
public void queueInputBuffer (int index, int offset, int size, long presentationTimeUs, int flags)
public void queueSecureInputBuffer (int index, int offset, MediaCodec.CryptoInfo info, long presentationTimeUs, int flags)
// 出力バッファを解放
public void releaseOutputBuffer (int index, boolean render)
public void releaseOutputBuffer (int index, long renderTimestampNs)

以下は、Android 5.0 以降の ByteBuffer の方法に適用されるものを主に紹介します。

Android 5.0 以降、ByteBuffer 配列の方法は非推奨とされ、公式サイトでは ByteBuffer が ByteBuffer 配列の方法に比べて一定の最適化が行われていると述べています。そのため、デバイスが条件を満たす場合は、できるだけ ByteBuffer に対応する API を使用し、非同期モードでデータを処理することが推奨されます。同期と非同期処理方式のコードの参考は以下の通りです。

  • 同期処理モード
MediaCodec codec = MediaCodec.createByCodecName(name);
 codec.configure(format, …);
 MediaFormat outputFormat = codec.getOutputFormat(); // option B
 codec.start();
 for (;;) {
  int inputBufferId = codec.dequeueInputBuffer(timeoutUs);
  if (inputBufferId >= 0) {
    ByteBuffer inputBuffer = codec.getInputBuffer(…);
    // 有効なデータで入力バッファを満たす

    codec.queueInputBuffer(inputBufferId, …);
  }
  int outputBufferId = codec.dequeueOutputBuffer(…);
  if (outputBufferId >= 0) {
    ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
    MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
    // bufferFormat と outputFormat は同じです
    // 出力バッファが準備できたら処理またはレンダリングされます

    codec.releaseOutputBuffer(outputBufferId, …);
  } else if (outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
    // 出力形式が変更され、以降は新しい形式を使用します。この時点で getOutputFormat() を使用して新しい形式を取得します。
    // 特定のバッファの形式を取得するために getOutputFormat(outputBufferId) を使用する場合は、形式の変化を監視する必要はありません。
    outputFormat = codec.getOutputFormat(); // option B
  }
 }
 codec.stop();
 codec.release();

具体的には、前回の記事のケースを参照してください:Camera2、MediaCodec で mp4 を録画

  • 非同期処理モード
MediaCodec codec = MediaCodec.createByCodecName(name);
 MediaFormat mOutputFormat; // メンバ変数
 codec.setCallback(new MediaCodec.Callback() {
  @Override
  void onInputBufferAvailable(MediaCodec mc, int inputBufferId) {
    ByteBuffer inputBuffer = codec.getInputBuffer(inputBufferId);
    // inputBuffer に有効なデータを埋め込む

    codec.queueInputBuffer(inputBufferId, …);
  }
 
  @Override
  void onOutputBufferAvailable(MediaCodec mc, int outputBufferId, …) {
    ByteBuffer outputBuffer = codec.getOutputBuffer(outputBufferId);
    MediaFormat bufferFormat = codec.getOutputFormat(outputBufferId); // option A
    // bufferFormat は mOutputFormat と同等です
    // outputBuffer は処理またはレンダリングの準備が整いました。

    codec.releaseOutputBuffer(outputBufferId, …);
  }
 
  @Override
  void onOutputFormatChanged(MediaCodec mc, MediaFormat format) {
    // 以降のデータは新しい形式に準拠します。
    // getOutputFormat(outputBufferId) を使用して形式を取得する場合は無視できます。
    mOutputFormat = format; // option B
  }
 
  @Override
  void onError(…) {

  }
 });
 codec.configure(format, …);
 mOutputFormat = codec.getOutputFormat(); // option B
 codec.start();
 // 処理が完了するのを待つ
 codec.stop();
 codec.release();

処理するデータが終了した場合(End-of-stream)、ストリームの終了をマークする必要があります。最後の有効な入力バッファを使用して queueInputBuffer を提出する際に、flags を BUFFER_FLAG_END_OF_STREAM として指定することで終了をマークできます。または、最後の有効な入力バッファの後に空の入力バッファを提出し、ストリーム終了フラグを設定して終了をマークすることもできます。この時点で、入力バッファを再提出することはできません。コーデックが flush、stop、restart されない限り、出力バッファは最終的に dequeueOutputBuffer または Callback#onOutputBufferAvailable で指定された同じストリーム終了フラグを通じて返され続け、最終的に出力ストリームの終了が通知されます。

入力 Surface をコーデックの入力として使用する場合、アクセス可能な入力バッファはなく、入力バッファは自動的にこの Surface からコーデックに提出されます。これは、入力プロセスを省略したことに相当します。この入力 Surface は createInputSurface メソッドを使用して作成できます。この時点で signalEndOfInputStream を呼び出すと、ストリーム終了の信号が送信されます。呼び出した後、入力 Surface はコーデックにデータを提出するのを即座に停止します。重要な API は以下の通りです。

// 入力 Surface を作成。configure の後、start の前に呼び出す必要があります。
public Surface createInputSurface ()
// 入力 Surface を設定
public void setInputSurface (Surface surface)
// ストリーム終了の信号を送信
public void signalEndOfInputStream ()

同様に、出力 Surface を使用する場合、関連する出力バッファの機能は置き換えられます。setOutputSurface を使用してコーデックの出力として Surface を設定できます。出力バッファの各出力をレンダリングするかどうかを選択できます。重要な API は以下の通りです。

// 出力 Surface を設定
public void setOutputSurface (Surface surface)
// false はこのバッファをレンダリングしないことを示し、true はデフォルトのタイムスタンプでこのバッファをレンダリングすることを示します。
public void releaseOutputBuffer (int index, boolean render)
// 指定されたタイムスタンプでこのバッファをレンダリングします。
public void releaseOutputBuffer (int index, long renderTimestampNs)

アダプティブ再生のサポート#

MediaCodec が動画デコーダーとして使用される場合、デコーダーがアダプティブ再生をサポートしているかどうかを次の方法で確認できます。つまり、デコーダーがシームレスな解像度変更をサポートしているかどうかです。

// 特定の機能をサポートしているかどうか。CodecCapabilities#FEATURE_AdaptivePlayback はアダプティブ再生のサポートに対応します。
public boolean isFeatureSupported (String name)

この時点で、デコーダーが Surface 上でデコードされるように設定されている場合にのみ、アダプティブ再生機能がアクティブになります。動画デコード時に start または flush が呼び出された後、完全に独立してデコードできるのは、キーフレーム(key-frame)だけです。つまり、通常の I フレームです。他のフレームはこれに基づいてデコードされます。異なる形式に対応するキーフレームは以下の通りです。

image

異なるデコーダーはアダプティブ再生のサポート能力が異なり、シーク操作後の処理も異なります。この部分の内容は、後の具体的な実践後に整理します。

MediaCodec の例外処理#

MediaCodec 使用中の例外処理について、CodecException 例外について触れておきます。これは一般的にコーデック内部の例外によって引き起こされます。たとえば、メディアコンテンツの破損、ハードウェアの故障、リソースの枯渇などです。以下の方法で判断して、さらなる処理を行うことができます。

// true は stop、configure、start で回復可能であることを示します。
public boolean isRecoverable ()
// true は一時的な問題を示し、エンコードまたはデコード操作は後で再試行されます。
public boolean isTransient ()

isRecoverable と isTransient の両方が false を返す場合は、reset または release 操作を通じてリソースを解放し、再度作業を行う必要があります。両者が同時に true を返すことはありません。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。