From 84a6156d653bade6d303b8f78afcd60bb9a440b4 Mon Sep 17 00:00:00 2001 From: Alvaro Velad Galvan Date: Tue, 16 May 2023 15:11:10 +0200 Subject: [PATCH 1/2] feat: Add AAC transmuxer --- build/types/transmuxer | 2 + lib/transmuxer/aac_transmuxer.js | 223 ++++++++++++++++++ lib/transmuxer/adts.js | 175 ++++++++++++++ lib/transmuxer/muxjs_transmuxer.js | 42 +--- lib/util/mp4_generator.js | 11 +- lib/util/platform.js | 14 ++ lib/util/player_configuration.js | 10 +- shaka-player.uncompiled.js | 2 + test/media/media_source_engine_integration.js | 5 +- .../muxjs_transmuxer_integration.js | 18 -- test/transmuxer/transmuxer_integration.js | 3 + 11 files changed, 429 insertions(+), 76 deletions(-) create mode 100644 lib/transmuxer/aac_transmuxer.js create mode 100644 lib/transmuxer/adts.js diff --git a/build/types/transmuxer b/build/types/transmuxer index 3a14b6ce9f..7b0e7584ac 100644 --- a/build/types/transmuxer +++ b/build/types/transmuxer @@ -1,5 +1,7 @@ # Optional plugins related to transmuxer. ++../../lib/transmuxer/aac_transmuxer.js ++../../lib/transmuxer/adts.js +../../lib/transmuxer/mp3_transmuxer.js +../../lib/transmuxer/mpeg_audio.js +../../lib/transmuxer/muxjs_transmuxer.js diff --git a/lib/transmuxer/aac_transmuxer.js b/lib/transmuxer/aac_transmuxer.js new file mode 100644 index 0000000000..20251b1a26 --- /dev/null +++ b/lib/transmuxer/aac_transmuxer.js @@ -0,0 +1,223 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.AacTransmuxer'); + +goog.require('shaka.media.Capabilities'); +goog.require('shaka.transmuxer.ADTS'); +goog.require('shaka.transmuxer.TransmuxerEngine'); +goog.require('shaka.util.BufferUtils'); +goog.require('shaka.util.Error'); +goog.require('shaka.util.Id3Utils'); +goog.require('shaka.util.ManifestParserUtils'); +goog.require('shaka.util.MimeUtils'); +goog.require('shaka.util.Mp4Generator'); +goog.require('shaka.util.Uint8ArrayUtils'); + + +/** + * @implements {shaka.extern.Transmuxer} + * @export + */ +shaka.transmuxer.AacTransmuxer = class { + /** + * @param {string} mimeType + */ + constructor(mimeType) { + /** @private {string} */ + this.originalMimeType_ = mimeType; + + /** @private {number} */ + this.frameIndex_ = 0; + + /** @private {!Map.} */ + this.initSegments = new Map(); + } + + + /** + * @override + * @export + */ + destroy() { + // Nothing + } + + + /** + * Check if the mime type and the content type is supported. + * @param {string} mimeType + * @param {string=} contentType + * @return {boolean} + * @override + * @export + */ + isSupported(mimeType, contentType) { + const Capabilities = shaka.media.Capabilities; + + if (!this.isAacContainer_(mimeType)) { + return false; + } + const ContentType = shaka.util.ManifestParserUtils.ContentType; + return Capabilities.isTypeSupported( + this.convertCodecs(ContentType.AUDIO, mimeType)); + } + + + /** + * Check if the mimetype is 'audio/aac'. + * @param {string} mimeType + * @return {boolean} + * @private + */ + isAacContainer_(mimeType) { + return mimeType.toLowerCase().split(';')[0] == 'audio/aac'; + } + + + /** + * @override + * @export + */ + convertCodecs(contentType, mimeType) { + if (this.isAacContainer_(mimeType)) { + const codecs = shaka.util.MimeUtils.getCodecs(mimeType); + if (codecs != '') { + return `audio/mp4; codecs="${codecs}"`; + } + return 'audio/mp4; codecs="mp4a.40.2"'; + } + return mimeType; + } + + + /** + * @override + * @export + */ + getOrginalMimeType() { + return this.originalMimeType_; + } + + + /** + * @override + * @export + */ + transmux(data, stream, reference, duration) { + const ADTS = shaka.transmuxer.ADTS; + const Uint8ArrayUtils = shaka.util.Uint8ArrayUtils; + + const uint8ArrayData = shaka.util.BufferUtils.toUint8(data); + + // Check for the ADTS sync word + // Look for ADTS header | 1111 1111 | 1111 X00X | where X can be + // either 0 or 1 + // Layer bits (position 14 and 15) in header should be always 0 for ADTS + // More info https://wiki.multimedia.cx/index.php?title=ADTS + const id3Data = shaka.util.Id3Utils.getID3Data(uint8ArrayData); + let offset = id3Data.length; + for (; offset < uint8ArrayData.length; offset++) { + if (ADTS.probe(uint8ArrayData, offset)) { + break; + } + } + + let timestamp = reference.endTime * 1000; + + const frames = shaka.util.Id3Utils.getID3Frames(id3Data); + if (frames.length && reference) { + const metadataTimestamp = frames.find((frame) => { + return frame.description === + 'com.apple.streaming.transportStreamTimestamp'; + }); + if (metadataTimestamp) { + timestamp = /** @type {!number} */(metadataTimestamp.data); + } + } + + const info = ADTS.parseInfo(uint8ArrayData, offset); + if (!info) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED)); + } + stream.audioSamplingRate = info.sampleRate; + stream.channelsCount = info.channelCount; + stream.codecs = info.codec; + stream.type = 'audio'; + + /** @type {!Array.} */ + const samples = []; + + while (offset < uint8ArrayData.length) { + const header = ADTS.parseHeader(uint8ArrayData, offset); + if (!header) { + return Promise.reject(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.MEDIA, + shaka.util.Error.Code.TRANSMUXING_FAILED)); + } + const length = header.headerLength + header.frameLength; + if (offset + length <= uint8ArrayData.length) { + const data = uint8ArrayData.subarray( + offset + header.headerLength, offset + length); + samples.push({ + data: data, + size: header.frameLength, + duration: ADTS.AAC_SAMPLES_PER_FRAME, + cts: 0, + flags: { + isLeading: 0, + isDependedOn: 0, + hasRedundancy: 0, + degradPrio: 0, + dependsOn: 2, + isNonSync: 0, + }, + }); + } + offset += length; + } + + /** @type {number} */ + const sampleRate = info.sampleRate; + /** @type {number} */ + const baseMediaDecodeTime = Math.floor(timestamp * sampleRate / 1000); + + /** @type {shaka.util.Mp4Generator.StreamInfo} */ + const streamInfo = { + timescale: sampleRate, + duration: duration, + videoNalus: [], + data: { + sequenceNumber: this.frameIndex_, + baseMediaDecodeTime: baseMediaDecodeTime, + samples: samples, + }, + stream: stream, + }; + const mp4Generator = new shaka.util.Mp4Generator(streamInfo); + let initSegment; + if (!this.initSegments.has(stream.id)) { + initSegment = mp4Generator.initSegment(); + this.initSegments.set(stream.id, initSegment); + } else { + initSegment = this.initSegments.get(stream.id); + } + const segmentData = mp4Generator.segmentData(); + + this.frameIndex_++; + const transmuxData = Uint8ArrayUtils.concat(initSegment, segmentData); + return Promise.resolve(transmuxData); + } +}; + +shaka.transmuxer.TransmuxerEngine.registerTransmuxer( + 'audio/aac', + () => new shaka.transmuxer.AacTransmuxer('audio/aac'), + shaka.transmuxer.TransmuxerEngine.PluginPriority.FALLBACK); diff --git a/lib/transmuxer/adts.js b/lib/transmuxer/adts.js new file mode 100644 index 0000000000..63ab39044d --- /dev/null +++ b/lib/transmuxer/adts.js @@ -0,0 +1,175 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +goog.provide('shaka.transmuxer.ADTS'); + + +/** + * ADTS utils + */ +shaka.transmuxer.ADTS = class { + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {?{headerLength: number, frameLength: number}} + */ + static parseHeader(data, offset) { + const ADTS = shaka.transmuxer.ADTS; + // The protection skip bit tells us if we have 2 bytes of CRC data at the + // end of the ADTS header + const headerLength = ADTS.getHeaderLength(data, offset); + if (offset + headerLength <= data.length) { + // retrieve frame size + const frameLength = ADTS.getFullFrameLength(data, offset) - headerLength; + if (frameLength > 0) { + return { + headerLength, + frameLength, + }; + } + } + return null; + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {?{sampleRate: number, channelCount: number, codec: string}} + */ + static parseInfo(data, offset) { + const adtsSamplingRates = [ + 96000, + 88200, + 64000, + 48000, + 44100, + 32000, + 24000, + 22050, + 16000, + 12000, + 11025, + 8000, + 7350, + ]; + const adtsSamplingIndex = (data[offset + 2] & 0x3c) >>> 2; + if (adtsSamplingIndex > adtsSamplingRates.length - 1) { + return null; + } + const adtsObjectType = ((data[offset + 2] & 0xc0) >>> 6) + 1; + let adtsChannelConfig = (data[offset + 2] & 0x01) << 2; + adtsChannelConfig |= (data[offset + 3] & 0xc0) >>> 6; + return { + sampleRate: adtsSamplingRates[adtsSamplingIndex], + channelCount: adtsChannelConfig, + codec: 'mp4a.40.' + adtsObjectType, + }; + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {boolean} + */ + static isHeaderPattern(data, offset) { + return data[offset] === 0xff && (data[offset + 1] & 0xf6) === 0xf0; + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {number} + */ + static getHeaderLength(data, offset) { + return data[offset + 1] & 0x01 ? 7 : 9; + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {number} + */ + static getFullFrameLength(data, offset) { + return ((data[offset + 3] & 0x03) << 11) | + (data[offset + 4] << 3) | + ((data[offset + 5] & 0xe0) >>> 5); + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {boolean} + */ + static canGetFrameLength(data, offset) { + return offset + 5 < data.length; + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {boolean} + */ + static isHeader(data, offset) { + const ADTS = shaka.transmuxer.ADTS; + // Look for ADTS header | 1111 1111 | 1111 X00X | where X can be + // either 0 or 1 + // Layer bits (position 14 and 15) in header should be always 0 for ADTS + // More info https://wiki.multimedia.cx/index.php?title=ADTS + return offset + 1 < data.length && ADTS.isHeaderPattern(data, offset); + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {boolean} + */ + static canParse(data, offset) { + const ADTS = shaka.transmuxer.ADTS; + return ADTS.canGetFrameLength(data, offset) && + ADTS.isHeaderPattern(data, offset) && + ADTS.getFullFrameLength(data, offset) <= data.length - offset; + } + + /** + * @param {!Uint8Array} data + * @param {!number} offset + * @return {boolean} + */ + static probe(data, offset) { + const ADTS = shaka.transmuxer.ADTS; + // same as isHeader but we also check that ADTS frame follows last ADTS + // frame or end of data is reached + if (ADTS.isHeader(data, offset)) { + // ADTS header Length + const headerLength = ADTS.getHeaderLength(data, offset); + if (offset + headerLength >= data.length) { + return false; + } + // ADTS frame Length + const frameLength = ADTS.getFullFrameLength(data, offset); + if (frameLength <= headerLength) { + return false; + } + + const newOffset = offset + frameLength; + return newOffset === data.length || ADTS.isHeader(data, newOffset); + } + return false; + } + + /** + * @param {!number} samplerate + * @return {number} + */ + static getFrameDuration(samplerate) { + return (shaka.transmuxer.ADTS.AAC_SAMPLES_PER_FRAME * 90000) / samplerate; + } +}; + +/** + * @const {number} + */ +shaka.transmuxer.ADTS.AAC_SAMPLES_PER_FRAME = 1024; diff --git a/lib/transmuxer/muxjs_transmuxer.js b/lib/transmuxer/muxjs_transmuxer.js index 37fa23ace3..16c4e04cdd 100644 --- a/lib/transmuxer/muxjs_transmuxer.js +++ b/lib/transmuxer/muxjs_transmuxer.js @@ -13,7 +13,6 @@ goog.require('shaka.transmuxer.TransmuxerEngine'); goog.require('shaka.util.BufferUtils'); goog.require('shaka.util.Error'); goog.require('shaka.util.ManifestParserUtils'); -goog.require('shaka.util.MimeUtils'); goog.require('shaka.util.PublicPromise'); goog.require('shaka.util.Uint8ArrayUtils'); @@ -79,16 +78,11 @@ shaka.transmuxer.MuxjsTransmuxer = class { const Capabilities = shaka.media.Capabilities; const isTs = this.isTsContainer_(mimeType); - const isAac = this.isAacContainer_(mimeType); - if (!this.muxjs_ || (!isTs && !isAac)) { + if (!this.muxjs_ || !isTs) { return false; } - if (isAac) { - return Capabilities.isTypeSupported(this.convertAacCodecs_(mimeType)); - } - if (contentType) { return Capabilities.isTypeSupported( this.convertTsCodecs_(contentType, mimeType)); @@ -103,17 +97,6 @@ shaka.transmuxer.MuxjsTransmuxer = class { } - /** - * Check if the mimetype is 'audio/aac'. - * @param {string} mimeType - * @return {boolean} - * @private - */ - isAacContainer_(mimeType) { - return mimeType.toLowerCase().split(';')[0] == 'audio/aac'; - } - - /** * Check if the mimetype contains 'mp2t'. * @param {string} mimeType @@ -130,30 +113,13 @@ shaka.transmuxer.MuxjsTransmuxer = class { * @export */ convertCodecs(contentType, mimeType) { - if (this.isAacContainer_(mimeType)) { - return this.convertAacCodecs_(mimeType); - } else if (this.isTsContainer_(mimeType)) { + if (this.isTsContainer_(mimeType)) { return this.convertTsCodecs_(contentType, mimeType); } return mimeType; } - /** - * For aac stream, convert its codecs to MP4 codecs. - * @param {string} mimeType - * @return {string} - * @private - */ - convertAacCodecs_(mimeType) { - const codecs = shaka.util.MimeUtils.getCodecs(mimeType); - if (codecs != '') { - return `audio/mp4; codecs="${codecs}"`; - } - return 'audio/mp4; codecs="mp4a.40.2"'; - } - - /** * For transport stream, convert its codecs to MP4 codecs. * @param {string} contentType @@ -270,10 +236,6 @@ shaka.transmuxer.MuxjsTransmuxer = class { } }; -shaka.transmuxer.TransmuxerEngine.registerTransmuxer( - 'audio/aac', - () => new shaka.transmuxer.MuxjsTransmuxer('audio/aac'), - shaka.transmuxer.TransmuxerEngine.PluginPriority.FALLBACK); shaka.transmuxer.TransmuxerEngine.registerTransmuxer( 'video/mp2t', () => new shaka.transmuxer.MuxjsTransmuxer('video/mp2t'), diff --git a/lib/util/mp4_generator.js b/lib/util/mp4_generator.js index 9a1766a86a..50ff84d7c5 100644 --- a/lib/util/mp4_generator.js +++ b/lib/util/mp4_generator.js @@ -714,15 +714,12 @@ shaka.util.Mp4Generator = class { * @return {!Uint8Array} */ segmentData() { - const Mp4Generator = shaka.util.Mp4Generator; const movie = this.moof_(); const mdat = this.mdat_(); - const length = Mp4Generator.FTYP_.byteLength + movie.byteLength + - mdat.byteLength; + const length = movie.byteLength + mdat.byteLength; const result = new Uint8Array(length); - result.set(Mp4Generator.FTYP_); - result.set(movie, Mp4Generator.FTYP_.byteLength); - result.set(mdat, Mp4Generator.FTYP_.byteLength + movie.byteLength); + result.set(movie); + result.set(mdat, movie.byteLength); return result; } @@ -832,7 +829,7 @@ shaka.util.Mp4Generator = class { ...this.breakNumberIntoBytes_(upperWordBaseMediaDecodeTime, 4), ...this.breakNumberIntoBytes_(lowerWordBaseMediaDecodeTime, 4), ]); - return Mp4Generator.box('mfhd', bytes); + return Mp4Generator.box('tfdt', bytes); } /** diff --git a/lib/util/platform.js b/lib/util/platform.js index adaff96353..4eb5f6e47e 100644 --- a/lib/util/platform.js +++ b/lib/util/platform.js @@ -410,6 +410,20 @@ shaka.util.Platform = class { return Platform.isTizen() || Platform.isXboxOne(); } + /** + * Returns true if the platform supports SourceBuffer "sequence mode". + * + * @return {boolean} + */ + static supportsSequenceMode() { + const Platform = shaka.util.Platform; + if (Platform.isTizen3() || Platform.isTizen2() || + Platform.isWebOS3() || Platform.isPS4()) { + return false; + } + return true; + } + /** * Returns true if MediaKeys is polyfilled * diff --git a/lib/util/player_configuration.js b/lib/util/player_configuration.js index 9290a5d1a8..af23f1d24b 100644 --- a/lib/util/player_configuration.js +++ b/lib/util/player_configuration.js @@ -92,14 +92,6 @@ shaka.util.PlayerConfiguration = class { minHdcpVersion: '', }; - let supportsSequenceMode = true; - if (shaka.util.Platform.isTizen3() || - shaka.util.Platform.isTizen2() || - shaka.util.Platform.isWebOS3() || - shaka.util.Platform.isPS4()) { - supportsSequenceMode = false; - } - const manifest = { retryParameters: shaka.net.NetworkingEngine.defaultRetryParameters(), availabilityWindowOverride: NaN, @@ -152,7 +144,7 @@ shaka.util.PlayerConfiguration = class { 'video/mp2t; codecs="avc1.42E01E, mp4a.40.2"', useSafariBehaviorForLive: true, liveSegmentsDelay: 3, - sequenceMode: supportsSequenceMode, + sequenceMode: shaka.util.Platform.supportsSequenceMode(), ignoreManifestTimestampsInSegmentsMode: false, }, mss: { diff --git a/shaka-player.uncompiled.js b/shaka-player.uncompiled.js index b8d0711339..3d002e79c5 100644 --- a/shaka-player.uncompiled.js +++ b/shaka-player.uncompiled.js @@ -65,6 +65,8 @@ goog.require('shaka.text.WebVttGenerator'); goog.require('shaka.cea.CeaDecoder'); goog.require('shaka.cea.Mp4CeaParser'); goog.require('shaka.cea.TsCeaParser'); +goog.require('shaka.transmuxer.AacTransmuxer'); +goog.require('shaka.transmuxer.ADTS'); goog.require('shaka.transmuxer.Mp3Transmuxer'); goog.require('shaka.transmuxer.MpegAudio'); goog.require('shaka.transmuxer.TransmuxerEngine'); diff --git a/test/media/media_source_engine_integration.js b/test/media/media_source_engine_integration.js index 75290c3fe5..47dabe0b86 100644 --- a/test/media/media_source_engine_integration.js +++ b/test/media/media_source_engine_integration.js @@ -652,7 +652,8 @@ describe('MediaSourceEngine', () => { }); it('extracts ID3 metadata from AAC', async () => { - if (!MediaSource.isTypeSupported('audio/aac')) { + if (!MediaSource.isTypeSupported('audio/aac') || + !shaka.util.Platform.supportsSequenceMode()) { return; } metadata = shaka.test.TestScheme.DATA['id3-metadata_aac']; @@ -661,7 +662,7 @@ describe('MediaSourceEngine', () => { const audioType = ContentType.AUDIO; const initObject = new Map(); initObject.set(audioType, getFakeStream(metadata.audio)); - await mediaSourceEngine.init(initObject); + await mediaSourceEngine.init(initObject, /* sequenceMode= */ true); await append(ContentType.AUDIO, 0); expect(onMetadata).toHaveBeenCalled(); diff --git a/test/transmuxer/muxjs_transmuxer_integration.js b/test/transmuxer/muxjs_transmuxer_integration.js index b4c7b66876..b817a6f551 100644 --- a/test/transmuxer/muxjs_transmuxer_integration.js +++ b/test/transmuxer/muxjs_transmuxer_integration.js @@ -12,7 +12,6 @@ describe('MuxjsTransmuxer', () => { const mp4MimeType = 'video/mp4; codecs="avc1.42E01E"'; const transportStreamVideoMimeType = 'video/mp2t; codecs="avc1.42E01E"'; const transportStreamAudioMimeType = 'video/mp2t; codecs="mp4a.40.2"'; - const aacAudioMimeType = 'audio/aac'; /** @type {!ArrayBuffer} */ let videoSegment; @@ -28,10 +27,6 @@ describe('MuxjsTransmuxer', () => { transmuxer = new shaka.transmuxer.MuxjsTransmuxer('video/mp2t'); } - function useAacTransmuxer() { - transmuxer = new shaka.transmuxer.MuxjsTransmuxer('audio/aac'); - } - beforeAll(async () => { const responses = await Promise.all([ shaka.test.Util.fetch(videoSegmentUri), @@ -84,14 +79,6 @@ describe('MuxjsTransmuxer', () => { expect(convertedAudioCodecs).toBe(expectedAudioCodecs); }); - it('returns converted codecs for AAC', () => { - useAacTransmuxer(); - const convertedAacCodecs = - convertCodecs(ContentType.AUDIO, aacAudioMimeType); - const expectedAacCodecs = 'audio/mp4; codecs="mp4a.40.2"'; - expect(convertedAacCodecs).toBe(expectedAacCodecs); - }); - it('converts legacy avc1 codec strings', () => { useTsTransmuxer(); expect( @@ -115,11 +102,6 @@ describe('MuxjsTransmuxer', () => { }); }); - it('getOrginalMimeType returns the correct mimeType', () => { - useAacTransmuxer(); - expect(transmuxer.getOrginalMimeType()).toBe(aacAudioMimeType); - }); - describe('transmuxing', () => { it('transmux video from TS to MP4', async () => { useTsTransmuxer(); diff --git a/test/transmuxer/transmuxer_integration.js b/test/transmuxer/transmuxer_integration.js index 5f1f2256a3..98a686aefe 100644 --- a/test/transmuxer/transmuxer_integration.js +++ b/test/transmuxer/transmuxer_integration.js @@ -84,6 +84,9 @@ describe('Transmuxer Player', () => { }); it('raw MP3', async () => { + if (!MediaSource.isTypeSupported('audio/mp4; codecs="mp3"')) { + return; + } // eslint-disable-next-line max-len const url = 'https://pl.streamingvideoprovider.com/mp3-playlist/playlist.m3u8'; From 6ea8f6bc02d15385650a0814d1d07060aec63b93 Mon Sep 17 00:00:00 2001 From: Alvaro Velad Galvan Date: Sun, 28 May 2023 08:04:36 +0200 Subject: [PATCH 2/2] Simplify condition --- lib/transmuxer/aac_transmuxer.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/transmuxer/aac_transmuxer.js b/lib/transmuxer/aac_transmuxer.js index 20251b1a26..f4797f4efa 100644 --- a/lib/transmuxer/aac_transmuxer.js +++ b/lib/transmuxer/aac_transmuxer.js @@ -85,10 +85,7 @@ shaka.transmuxer.AacTransmuxer = class { convertCodecs(contentType, mimeType) { if (this.isAacContainer_(mimeType)) { const codecs = shaka.util.MimeUtils.getCodecs(mimeType); - if (codecs != '') { - return `audio/mp4; codecs="${codecs}"`; - } - return 'audio/mp4; codecs="mp4a.40.2"'; + return `audio/mp4; codecs="${codecs || 'mp4a.40.2'}"`; } return mimeType; }