mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
Make FLAC encoder bit depth depend on input sample format, add sampleFormat audio transformation option, fix incorrect bitrate validation for FLAC (closes #357)
This commit is contained in:
+45
-11
@@ -50,7 +50,15 @@ import {
|
||||
} from './misc';
|
||||
import { Output, OutputTrackGroup, TrackType } from './output';
|
||||
import { Mp4OutputFormat } from './output-format';
|
||||
import { AudioSample, clampCropRectangle, CropRectangle, validateCropRectangle, VideoSample } from './sample';
|
||||
import {
|
||||
AudioSample,
|
||||
audioSampleToInterleavedFormat,
|
||||
clampCropRectangle,
|
||||
CropRectangle,
|
||||
toInterleavedAudioFormat,
|
||||
validateCropRectangle,
|
||||
VideoSample,
|
||||
} from './sample';
|
||||
import { MetadataTags, validateMetadataTags } from './metadata';
|
||||
import { NullTarget } from './target';
|
||||
import { AudioResampler } from './resample';
|
||||
@@ -261,6 +269,13 @@ export type ConversionAudioOptions = {
|
||||
numberOfChannels?: number;
|
||||
/** The desired sample rate of the output audio, in hertz. */
|
||||
sampleRate?: number;
|
||||
/**
|
||||
* The desired sample format (and therefore bit depth) of the audio samples before they are passed to the encoder.
|
||||
* Can be used to control bit depth with certain output codecs such as FLAC.
|
||||
*
|
||||
* Setting this field forces audio transcoding.
|
||||
*/
|
||||
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
|
||||
/** The desired output audio codec. */
|
||||
codec?: AudioCodec;
|
||||
/** The desired bitrate of the output audio. */
|
||||
@@ -442,6 +457,12 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions) => {
|
||||
) {
|
||||
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
audioOptions?.sampleFormat !== undefined
|
||||
&& !['u8', 's16', 's32', 'f32'].includes(audioOptions.sampleFormat)
|
||||
) {
|
||||
throw new TypeError('options.audio.sampleFormat, when provided, must be one of: u8, s16, s32, f32.');
|
||||
}
|
||||
if (audioOptions?.process !== undefined && typeof audioOptions.process !== 'function') {
|
||||
throw new TypeError('options.audio.process, when provided, must be a function.');
|
||||
}
|
||||
@@ -1347,7 +1368,7 @@ export class Conversion {
|
||||
timestamp: lastCanvasTimestamp! + i / frameRate,
|
||||
duration: 1 / frameRate,
|
||||
});
|
||||
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
|
||||
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
|
||||
sample.close();
|
||||
}
|
||||
};
|
||||
@@ -1384,7 +1405,7 @@ export class Conversion {
|
||||
timestamp: adjustedSampleTimestamp,
|
||||
duration: frameRate !== undefined ? 1 / frameRate : duration,
|
||||
});
|
||||
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
|
||||
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
|
||||
sample.close();
|
||||
|
||||
if (frameRate !== undefined) {
|
||||
@@ -1425,7 +1446,7 @@ export class Conversion {
|
||||
for (let i = 1; i < frameDifference; i++) {
|
||||
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
|
||||
lastSample.setDuration(1 / frameRate);
|
||||
await this._registerVideoSample(track, trackOptions, outputTrackId, source, lastSample);
|
||||
await this._registerVideoSample(trackOptions, outputTrackId, source, lastSample);
|
||||
}
|
||||
|
||||
lastSample.close();
|
||||
@@ -1464,7 +1485,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
sample.setTimestamp(adjustedSampleTimestamp);
|
||||
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
|
||||
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
|
||||
|
||||
if (frameRate !== undefined) {
|
||||
lastSample = sample;
|
||||
@@ -1513,7 +1534,6 @@ export class Conversion {
|
||||
|
||||
/** @internal */
|
||||
async _registerVideoSample(
|
||||
track: InputVideoTrack,
|
||||
trackOptions: ConversionVideoOptions,
|
||||
outputTrackId: number,
|
||||
source: VideoSampleSource,
|
||||
@@ -1609,6 +1629,7 @@ export class Conversion {
|
||||
&& audioCodecs.includes(sourceCodec)
|
||||
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
||||
&& !trackOptions.process
|
||||
&& trackOptions.sampleFormat === undefined
|
||||
) {
|
||||
// Fast path, we can simply copy over the encoded packets
|
||||
|
||||
@@ -1745,7 +1766,7 @@ export class Conversion {
|
||||
// Offset the timestamp as needed
|
||||
sample.setTimestamp(sample.timestamp - this._startTimestamp);
|
||||
|
||||
await this._registerAudioSample(track, trackOptions, outputTrackId, source, sample);
|
||||
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
|
||||
sample.close();
|
||||
}
|
||||
|
||||
@@ -1778,16 +1799,25 @@ export class Conversion {
|
||||
|
||||
/** @internal */
|
||||
async _registerAudioSample(
|
||||
track: InputAudioTrack,
|
||||
trackOptions: ConversionAudioOptions,
|
||||
outputTrackId: number,
|
||||
source: AudioSampleSource,
|
||||
sample: AudioSample,
|
||||
inputSample: AudioSample,
|
||||
) {
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sample = inputSample;
|
||||
|
||||
if (
|
||||
trackOptions.sampleFormat !== undefined
|
||||
&& toInterleavedAudioFormat(sample.format) !== trackOptions.sampleFormat
|
||||
) {
|
||||
// Do a sample format conversion
|
||||
sample = audioSampleToInterleavedFormat(sample, trackOptions.sampleFormat);
|
||||
}
|
||||
|
||||
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
|
||||
|
||||
let finalSamples: AudioSample[];
|
||||
@@ -1823,8 +1853,12 @@ export class Conversion {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (sample !== inputSample) {
|
||||
sample.close();
|
||||
}
|
||||
|
||||
for (const finalSample of finalSamples) {
|
||||
if (finalSample !== sample) {
|
||||
if (finalSample !== inputSample) {
|
||||
finalSample.close();
|
||||
}
|
||||
}
|
||||
@@ -1857,7 +1891,7 @@ export class Conversion {
|
||||
onSample: async (sample) => {
|
||||
sample.setTimestamp(sample.timestamp - this._startTimestamp);
|
||||
|
||||
await this._registerAudioSample(track, trackOptions, outputTrackId, source, sample);
|
||||
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
|
||||
sample.close();
|
||||
},
|
||||
});
|
||||
|
||||
+12
-1
@@ -386,6 +386,11 @@ export type AudioTransformOptions = {
|
||||
numberOfChannels?: number;
|
||||
/** The desired output sample rate in hertz to resample to. */
|
||||
sampleRate?: number;
|
||||
/**
|
||||
* The desired sample format (and therefore bit depth) of the audio samples before they are passed to the encoder.
|
||||
* Can be used to control bit depth with certain output codecs such as FLAC.
|
||||
*/
|
||||
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
|
||||
/**
|
||||
* Allows for custom user-defined processing of audio samples, e.g. for applying audio effects or timestamp
|
||||
* modifications. Called for each audio sample after resampling and remixing.
|
||||
@@ -406,7 +411,7 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
|
||||
}
|
||||
if (
|
||||
config.bitrate === undefined
|
||||
&& (!(PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
|
||||
&& !((PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
|
||||
) {
|
||||
throw new TypeError('config.bitrate must be provided for compressed audio codecs.');
|
||||
}
|
||||
@@ -433,6 +438,12 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
|
||||
) {
|
||||
throw new TypeError('config.transform.sampleRate, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
config.transform.sampleFormat !== undefined
|
||||
&& !['u8', 's16', 's32', 'f32'].includes(config.transform.sampleFormat)
|
||||
) {
|
||||
throw new TypeError('config.transform.sampleFormat, when provided, must be one of: u8, s16, s32, f32.');
|
||||
}
|
||||
if (config.transform.process !== undefined && typeof config.transform.process !== 'function') {
|
||||
throw new TypeError('config.transform.process, when provided, must be a function.');
|
||||
}
|
||||
|
||||
+82
-82
@@ -22,48 +22,48 @@ if ((globalThis as Record<symbol, unknown>)[MEDIABUNNY_LOADED_SYMBOL]) {
|
||||
|
||||
export {
|
||||
Output,
|
||||
OutputOptions,
|
||||
type OutputOptions,
|
||||
OutputTrack,
|
||||
OutputVideoTrack,
|
||||
OutputAudioTrack,
|
||||
OutputSubtitleTrack,
|
||||
OutputTrackGroup,
|
||||
BaseTrackMetadata,
|
||||
VideoTrackMetadata,
|
||||
AudioTrackMetadata,
|
||||
SubtitleTrackMetadata,
|
||||
OutputEvents,
|
||||
type BaseTrackMetadata,
|
||||
type VideoTrackMetadata,
|
||||
type AudioTrackMetadata,
|
||||
type SubtitleTrackMetadata,
|
||||
type OutputEvents,
|
||||
} from './output';
|
||||
export {
|
||||
OutputFormat,
|
||||
AdtsOutputFormat,
|
||||
AdtsOutputFormatOptions,
|
||||
type AdtsOutputFormatOptions,
|
||||
CmafOutputFormat,
|
||||
CmafOutputFormatOptions,
|
||||
type CmafOutputFormatOptions,
|
||||
FlacOutputFormat,
|
||||
FlacOutputFormatOptions,
|
||||
type FlacOutputFormatOptions,
|
||||
HlsOutputFormat,
|
||||
HlsOutputFormatOptions,
|
||||
HlsOutputPlaylistInfo,
|
||||
HlsOutputSegmentInfo,
|
||||
type HlsOutputFormatOptions,
|
||||
type HlsOutputPlaylistInfo,
|
||||
type HlsOutputSegmentInfo,
|
||||
IsobmffOutputFormat,
|
||||
IsobmffOutputFormatOptions,
|
||||
type IsobmffOutputFormatOptions,
|
||||
MkvOutputFormat,
|
||||
MkvOutputFormatOptions,
|
||||
type MkvOutputFormatOptions,
|
||||
MovOutputFormat,
|
||||
Mp3OutputFormat,
|
||||
Mp3OutputFormatOptions,
|
||||
type Mp3OutputFormatOptions,
|
||||
Mp4OutputFormat,
|
||||
MpegTsOutputFormat,
|
||||
MpegTsOutputFormatOptions,
|
||||
type MpegTsOutputFormatOptions,
|
||||
OggOutputFormat,
|
||||
OggOutputFormatOptions,
|
||||
type OggOutputFormatOptions,
|
||||
WavOutputFormat,
|
||||
WavOutputFormatOptions,
|
||||
type WavOutputFormatOptions,
|
||||
WebMOutputFormat,
|
||||
WebMOutputFormatOptions,
|
||||
InclusiveIntegerRange,
|
||||
TrackCountLimits,
|
||||
type WebMOutputFormatOptions,
|
||||
type InclusiveIntegerRange,
|
||||
type TrackCountLimits,
|
||||
} from './output-format';
|
||||
export {
|
||||
MediaSource,
|
||||
@@ -76,17 +76,17 @@ export {
|
||||
EncodedAudioPacketSource,
|
||||
EncodedVideoPacketSource,
|
||||
MediaStreamAudioTrackSource,
|
||||
MediaStreamAudioTrackSourceOptions,
|
||||
type MediaStreamAudioTrackSourceOptions,
|
||||
MediaStreamVideoTrackSource,
|
||||
MediaStreamVideoTrackSourceOptions,
|
||||
type MediaStreamVideoTrackSourceOptions,
|
||||
TextSubtitleSource,
|
||||
VideoSampleSource,
|
||||
} from './media-source';
|
||||
export {
|
||||
MediaCodec,
|
||||
VideoCodec,
|
||||
AudioCodec,
|
||||
SubtitleCodec,
|
||||
type MediaCodec,
|
||||
type VideoCodec,
|
||||
type AudioCodec,
|
||||
type SubtitleCodec,
|
||||
VIDEO_CODECS,
|
||||
AUDIO_CODECS,
|
||||
PCM_AUDIO_CODECS,
|
||||
@@ -102,12 +102,12 @@ export {
|
||||
getDecodableAudioCodecs,
|
||||
} from './decode';
|
||||
export {
|
||||
VideoEncodingConfig,
|
||||
VideoEncodingAdditionalOptions,
|
||||
VideoTransformOptions,
|
||||
AudioEncodingConfig,
|
||||
AudioEncodingAdditionalOptions,
|
||||
AudioTransformOptions,
|
||||
type VideoEncodingConfig,
|
||||
type VideoEncodingAdditionalOptions,
|
||||
type VideoTransformOptions,
|
||||
type AudioEncodingConfig,
|
||||
type AudioEncodingAdditionalOptions,
|
||||
type AudioTransformOptions,
|
||||
canEncode,
|
||||
canEncodeVideo,
|
||||
canEncodeAudio,
|
||||
@@ -128,69 +128,69 @@ export {
|
||||
} from './encode';
|
||||
export {
|
||||
Target,
|
||||
TargetEvents,
|
||||
TargetRequest,
|
||||
type TargetEvents,
|
||||
type TargetRequest,
|
||||
AppendOnlyStreamTarget,
|
||||
BufferTarget,
|
||||
BufferTargetOptions,
|
||||
type BufferTargetOptions,
|
||||
FilePathTarget,
|
||||
FilePathTargetOptions,
|
||||
type FilePathTargetOptions,
|
||||
NullTarget,
|
||||
PathedTarget,
|
||||
RangedTarget,
|
||||
StreamTarget,
|
||||
StreamTargetOptions,
|
||||
StreamTargetChunk,
|
||||
type StreamTargetOptions,
|
||||
type StreamTargetChunk,
|
||||
} from './target';
|
||||
export {
|
||||
AnyIterable,
|
||||
type AnyIterable,
|
||||
ConcurrentRunner,
|
||||
EventEmitter,
|
||||
EventListenerOptions,
|
||||
FilePath,
|
||||
MaybePromise,
|
||||
type EventListenerOptions,
|
||||
type FilePath,
|
||||
type MaybePromise,
|
||||
} from './misc';
|
||||
export {
|
||||
PsshBox,
|
||||
type PsshBox,
|
||||
} from './isobmff/isobmff-misc';
|
||||
export {
|
||||
Rational,
|
||||
Rectangle,
|
||||
Rotation,
|
||||
SetOptional,
|
||||
SetRequired,
|
||||
type Rational,
|
||||
type Rectangle,
|
||||
type Rotation,
|
||||
type SetOptional,
|
||||
type SetRequired,
|
||||
} from './misc';
|
||||
export {
|
||||
TrackType,
|
||||
type TrackType,
|
||||
ALL_TRACK_TYPES,
|
||||
} from './output';
|
||||
export {
|
||||
Source,
|
||||
SourceEvents,
|
||||
type SourceEvents,
|
||||
SourceRef,
|
||||
SourceRequest,
|
||||
type SourceRequest,
|
||||
BlobSource,
|
||||
BlobSourceOptions,
|
||||
type BlobSourceOptions,
|
||||
BufferSource,
|
||||
CustomPathedSource,
|
||||
FilePathSource,
|
||||
FilePathSourceOptions,
|
||||
type FilePathSourceOptions,
|
||||
PathedSource,
|
||||
StreamSource,
|
||||
StreamSourceOptions,
|
||||
type StreamSourceOptions,
|
||||
RangedSource,
|
||||
ReadableStreamSource,
|
||||
ReadableStreamSourceOptions,
|
||||
type ReadableStreamSourceOptions,
|
||||
UrlSource,
|
||||
UrlSourceOptions,
|
||||
type UrlSourceOptions,
|
||||
} from './source';
|
||||
export {
|
||||
InputFormat,
|
||||
InputFormatOptions,
|
||||
type InputFormatOptions,
|
||||
AdtsInputFormat,
|
||||
FlacInputFormat,
|
||||
IsobmffInputFormat,
|
||||
IsobmffInputFormatOptions,
|
||||
type IsobmffInputFormatOptions,
|
||||
HlsInputFormat,
|
||||
MatroskaInputFormat,
|
||||
Mp3InputFormat,
|
||||
@@ -216,38 +216,38 @@ export {
|
||||
} from './input-format';
|
||||
export {
|
||||
Input,
|
||||
InputOptions,
|
||||
InputEvents,
|
||||
type InputOptions,
|
||||
type InputEvents,
|
||||
InputDisposedError,
|
||||
UnsupportedInputFormatError,
|
||||
} from './input';
|
||||
export {
|
||||
DurationMetadataRequestOptions,
|
||||
type DurationMetadataRequestOptions,
|
||||
} from './demuxer';
|
||||
export {
|
||||
InputTrack,
|
||||
InputVideoTrack,
|
||||
InputAudioTrack,
|
||||
InputTrackQuery,
|
||||
PacketStats,
|
||||
type InputTrackQuery,
|
||||
type PacketStats,
|
||||
asc,
|
||||
desc,
|
||||
prefer,
|
||||
} from './input-track';
|
||||
export {
|
||||
EncodedPacket,
|
||||
EncodedPacketSideData,
|
||||
PacketType,
|
||||
type EncodedPacketSideData,
|
||||
type PacketType,
|
||||
} from './packet';
|
||||
export {
|
||||
AudioSample,
|
||||
AudioSampleInit,
|
||||
AudioSampleCopyToOptions,
|
||||
type AudioSampleInit,
|
||||
type AudioSampleCopyToOptions,
|
||||
VideoSample,
|
||||
VideoSampleInit,
|
||||
VideoSamplePixelFormat,
|
||||
type VideoSampleInit,
|
||||
type VideoSamplePixelFormat,
|
||||
VideoSampleColorSpace,
|
||||
CropRectangle,
|
||||
type CropRectangle,
|
||||
VIDEO_SAMPLE_PIXEL_FORMATS,
|
||||
} from './sample';
|
||||
export {
|
||||
@@ -255,20 +255,20 @@ export {
|
||||
AudioSampleSink,
|
||||
BaseMediaSampleSink,
|
||||
CanvasSink,
|
||||
CanvasSinkOptions,
|
||||
type CanvasSinkOptions,
|
||||
EncodedPacketSink,
|
||||
PacketRetrievalOptions,
|
||||
type PacketRetrievalOptions,
|
||||
VideoSampleSink,
|
||||
WrappedAudioBuffer,
|
||||
WrappedCanvas,
|
||||
type WrappedAudioBuffer,
|
||||
type WrappedCanvas,
|
||||
} from './media-sink';
|
||||
export {
|
||||
Conversion,
|
||||
ConversionOptions,
|
||||
ConversionVideoOptions,
|
||||
ConversionAudioOptions,
|
||||
type ConversionOptions,
|
||||
type ConversionVideoOptions,
|
||||
type ConversionAudioOptions,
|
||||
ConversionCanceledError,
|
||||
DiscardedTrack,
|
||||
type DiscardedTrack,
|
||||
} from './conversion';
|
||||
export {
|
||||
CustomVideoDecoder,
|
||||
@@ -279,11 +279,11 @@ export {
|
||||
registerEncoder,
|
||||
} from './custom-coder';
|
||||
export {
|
||||
MetadataTags,
|
||||
AttachedImage,
|
||||
type MetadataTags,
|
||||
type AttachedImage,
|
||||
RichImageData,
|
||||
AttachedFile,
|
||||
TrackDisposition,
|
||||
type TrackDisposition,
|
||||
} from './metadata';
|
||||
|
||||
// 🐡🦔
|
||||
|
||||
+26
-1
@@ -50,7 +50,13 @@ import {
|
||||
customAudioEncoders,
|
||||
} from './custom-coder';
|
||||
import { EncodedPacket, EncodedPacketSideData } from './packet';
|
||||
import { AudioSample, clampCropRectangle, VideoSample } from './sample';
|
||||
import {
|
||||
AudioSample,
|
||||
audioSampleToInterleavedFormat,
|
||||
clampCropRectangle,
|
||||
toInterleavedAudioFormat,
|
||||
VideoSample,
|
||||
} from './sample';
|
||||
import {
|
||||
AudioEncodingConfig,
|
||||
buildAudioEncoderConfig,
|
||||
@@ -1888,6 +1894,21 @@ class AudioEncoderWrapper {
|
||||
private async processAndEncode(audioSample: AudioSample, shouldClose: boolean) {
|
||||
const config = this.encodingConfig;
|
||||
|
||||
if (
|
||||
config.transform?.sampleFormat !== undefined
|
||||
&& toInterleavedAudioFormat(audioSample.format) !== config.transform.sampleFormat
|
||||
) {
|
||||
// Do a sample format conversion
|
||||
const newSample = audioSampleToInterleavedFormat(audioSample, config.transform.sampleFormat);
|
||||
|
||||
if (shouldClose) {
|
||||
audioSample.close();
|
||||
}
|
||||
|
||||
audioSample = newSample;
|
||||
shouldClose = true;
|
||||
}
|
||||
|
||||
if (config.transform?.process) {
|
||||
let processed = config.transform.process(audioSample);
|
||||
if (processed instanceof Promise) {
|
||||
@@ -1910,6 +1931,10 @@ class AudioEncoderWrapper {
|
||||
}
|
||||
await this.encodeSample(sample, true);
|
||||
}
|
||||
|
||||
if (shouldClose) {
|
||||
audioSample.close();
|
||||
}
|
||||
} else {
|
||||
await this.encodeSample(audioSample, shouldClose);
|
||||
}
|
||||
|
||||
@@ -1953,6 +1953,21 @@ const isAudioData = (x: unknown): x is AudioData => {
|
||||
return typeof AudioData !== 'undefined' && x instanceof AudioData;
|
||||
};
|
||||
|
||||
export const toInterleavedAudioFormat = (format: AudioSampleFormat): 'u8' | 's16' | 's32' | 'f32' => {
|
||||
switch (format) {
|
||||
case 'u8-planar':
|
||||
return 'u8';
|
||||
case 's16-planar':
|
||||
return 's16';
|
||||
case 's32-planar':
|
||||
return 's32';
|
||||
case 'f32-planar':
|
||||
return 'f32';
|
||||
default:
|
||||
return format;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* WebKit has a bug where calling AudioData.copyTo with a format different from the source format
|
||||
* crashes the tab when there are more than 2 channels. This function works around that by always
|
||||
@@ -2061,3 +2076,18 @@ const doAudioDataCopyToWebKitWorkaround = (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const audioSampleToInterleavedFormat = (sample: AudioSample, format: 'u8' | 's16' | 's32' | 'f32') => {
|
||||
const size = sample.allocationSize({ format, planeIndex: 0 });
|
||||
const buffer = new ArrayBuffer(size);
|
||||
sample.copyTo(buffer, { format, planeIndex: 0 });
|
||||
|
||||
return new AudioSample({
|
||||
data: buffer,
|
||||
format,
|
||||
numberOfChannels: sample.numberOfChannels,
|
||||
sampleRate: sample.sampleRate,
|
||||
timestamp: sample.timestamp,
|
||||
duration: sample.duration,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user