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 ビットの符号付き整数音声データを含むバッファのチャネルを抽出するには、次のコードを使用できます。

// バッファ 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 には 3 つの状態があり、それぞれ実行中(Executing)、停止中(Stopped)、解放中(Released)です。実行中と停止中にはそれぞれ 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 を使用する際に、すべてがハードウェアデコードであるとは限りません。実際に使用するコーデックによって、ハードウェアデコードかソフトウェアデコードかが決まります。一般的に、ベンダーが提供するコーデックはハードウェアデコードであり、システムが提供するものはソフトウェアデコードです。以下は私(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 以降は、ButeBuffer の方式でデータを処理することが推奨されています。それ以前は、ButeBuffer 配列の方式でデータを処理することしかできませんでした。以下のように示します。

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 以降に適用される ButeBuffer の方式について主に紹介します。

Android 5.0 以降、ButeBuffer 配列の方式は非推奨となりました。公式サイトでは、ButeBuffer は ButeBuffer 配列の方式に対して一定の最適化が行われていると述べられています。そのため、デバイスが条件を満たしている場合は、できるだけ ButeBuffer に対応する 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 を提出する際に、フラグを BUFFER_FLAG_END_OF_STREAM として指定することで終了をマークできます。また、最後の有効な入力バッファの後に、空の入力バッファを提出してストリーム終了フラグを設定することでも終了をマークできます。この時点では、MediaCodec が flush、stop、restart されない限り、入力バッファを再提出することはできません。出力バッファは、最終的に dequeueOutputBuffer または Callback#onOutputBufferAvailable で返される BufferInfo に指定された同じストリーム終了フラグを通じて、最終的にストリーム終了を通知するまで返され続けます。

入力 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

異なるデコーダーは自適応再生のサポート能力が異なり、seek 操作後の処理も異なります。この部分の内容は、後の具体的な実践後に整理します。

MediaCodec の例外処理#

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

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

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

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