From 6d32de0fb1b14ba900a2bf5aafc6afd57503ffff Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Fri, 5 Sep 2025 13:41:09 +0200 Subject: [PATCH] Add metadata read/write support for Matroska --- dev/convert.html | 2 +- src/matroska/ebml.ts | 19 ++ src/matroska/matroska-demuxer.ts | 296 ++++++++++++++++++++++++++++++- src/matroska/matroska-muxer.ts | 271 ++++++++++++++++++++++++---- src/metadata.ts | 4 +- src/misc.ts | 27 +++ 6 files changed, 577 insertions(+), 42 deletions(-) diff --git a/dev/convert.html b/dev/convert.html index 10e164b..2b0e378 100644 --- a/dev/convert.html +++ b/dev/convert.html @@ -24,7 +24,7 @@ chunked: true, chunkSize: 2**20 }); - const outputFormat = new Mediabunny.Mp4OutputFormat({}); + const outputFormat = new Mediabunny.MkvOutputFormat({}); const button = document.createElement('button'); button.textContent = 'Cancel'; diff --git a/src/matroska/ebml.ts b/src/matroska/ebml.ts index 9da08d4..1d89de4 100644 --- a/src/matroska/ebml.ts +++ b/src/matroska/ebml.ts @@ -129,8 +129,27 @@ export enum EBMLId { ProjectionType = 0x7671, ProjectionPoseRoll = 0x7675, Attachments = 0x1941a469, + AttachedFile = 0x61a7, + FileDescription = 0x467e, + FileName = 0x466e, + FileMediaType = 0x4660, + FileData = 0x465c, + FileUID = 0x46ae, Chapters = 0x1043a770, Tags = 0x1254c367, + Tag = 0x7373, + Targets = 0x63c0, + TargetTypeValue = 0x68ca, + TargetType = 0x63ca, + TagTrackUID = 0x63c5, + TagEditionUID = 0x63c9, + TagChapterUID = 0x63c4, + TagAttachmentUID = 0x63c6, + SimpleTag = 0x67c8, + TagName = 0x45a3, + TagLanguage = 0x447a, + TagString = 0x4487, + TagBinary = 0x4485, } export const LEVEL_0_EBML_IDS: EBMLId[] = [ diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 21ba787..4dfab32 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -30,6 +30,7 @@ import { InputVideoTrack, InputVideoTrackBacking, } from '../input-track'; +import { MediaMetadata } from '../metadata'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, @@ -76,6 +77,8 @@ type Segment = { infoSeen: boolean; tracksSeen: boolean; cuesSeen: boolean; + attachmentsSeen: boolean; + tagsSeen: boolean; timestampScale: number; timestampFactor: number; @@ -90,6 +93,9 @@ type Segment = { clusters: Cluster[]; clusterLookupMutex: AsyncMutex; + + metadata: MediaMetadata; + metadataCollected: boolean; }; type SeekEntry = { @@ -198,6 +204,14 @@ export class MatroskaDemuxer extends Demuxer { currentCluster: Cluster | null = null; currentBlock: ClusterBlock | null = null; currentCueTime: number | null = null; + currentTagTargetIsMovie: boolean = true; + currentSimpleTagName: string | null = null; + currentAttachedFile: { + fileName: string | null; + fileMediaType: string | null; + fileData: Uint8Array | null; + fileDescription: string | null; + } | null = null; isWebM = false; @@ -232,6 +246,27 @@ export class MatroskaDemuxer extends Demuxer { }); } + async getMetadata() { + await this.readMetadata(); + + // Load metadata from each segment lazily (only once) + for (const segment of this.segments) { + if (!segment.metadataCollected) { + await this.loadSegmentMetadata(segment); + segment.metadataCollected = true; + } + } + + // This is kinda handwavy, and how we handle multiple segments isn't suuuuper well-defined anyway; so we just + // shallow-merge metadata from all (usually just one) segments. + let metadata: MediaMetadata = {}; + for (const segment of this.segments) { + metadata = { ...metadata, ...segment.metadata }; + } + + return metadata; + } + readMetadata() { return this.readMetadataPromise ??= (async () => { let currentPos = 0; @@ -311,6 +346,8 @@ export class MatroskaDemuxer extends Demuxer { infoSeen: false, tracksSeen: false, cuesSeen: false, + tagsSeen: false, + attachmentsSeen: false, timestampScale: -1, timestampFactor: -1, @@ -327,6 +364,9 @@ export class MatroskaDemuxer extends Demuxer { clusters: [], clusterLookupMutex: new AsyncMutex(), + + metadata: {}, + metadataCollected: false, }; this.segments.push(this.currentSegment); @@ -371,6 +411,22 @@ export class MatroskaDemuxer extends Demuxer { let slice = this.reader.requestSlice(dataStartPos, size); if (slice instanceof Promise) slice = await slice; + if (slice) { + this.readContiguousElements(slice); + } + } else if (id === EBMLId.Tags || id === EBMLId.Attachments) { + // Metadata found at the beginning of the segment, great, let's parse it + if (id === EBMLId.Tags) { + this.currentSegment.tagsSeen = true; + } else { + this.currentSegment.attachmentsSeen = true; + } + + assertDefinedSize(size); + + let slice = this.reader.requestSlice(dataStartPos, size); + if (slice instanceof Promise) slice = await slice; + if (slice) { this.readContiguousElements(slice); } @@ -386,10 +442,10 @@ export class MatroskaDemuxer extends Demuxer { } } - if (this.reader.fileSize !== null) { - // Sort the seek entries by file position so reading them exhibits a sequential pattern - this.currentSegment.seekEntries.sort((a, b) => a.segmentPosition - b.segmentPosition); + // Sort the seek entries by file position so reading them exhibits a sequential pattern + this.currentSegment.seekEntries.sort((a, b) => a.segmentPosition - b.segmentPosition); + if (this.reader.fileSize !== null) { // Use the seek head to read missing metadata elements for (const seekEntry of this.currentSegment.seekEntries) { const target = METADATA_ELEMENTS.find(x => x.id === seekEntry.id); @@ -745,6 +801,50 @@ export class MatroskaDemuxer extends Demuxer { } } + async loadSegmentMetadata(segment: Segment) { + for (const seekEntry of segment.seekEntries) { + if (seekEntry.id === EBMLId.Tags && !segment.tagsSeen) { + // We need to load the tags + } else if (seekEntry.id === EBMLId.Attachments && !segment.attachmentsSeen) { + // We need to load the attachments + } else { + continue; + } + + let slice = this.reader.requestSliceRange( + segment.dataStartPos + seekEntry.segmentPosition, + MIN_HEADER_SIZE, + MAX_HEADER_SIZE, + ); + if (slice instanceof Promise) slice = await slice; + if (!slice) continue; + + const header = readElementHeader(slice); + if (!header || header.id !== seekEntry.id) continue; + + const { size } = header; + assertDefinedSize(size); + + assert(!this.currentSegment); + this.currentSegment = segment; + + let dataSlice = this.reader.requestSlice(slice.filePos, size); + if (dataSlice instanceof Promise) dataSlice = await dataSlice; + if (dataSlice) { + this.readContiguousElements(dataSlice); + } + + this.currentSegment = null; + + // Mark as seen + if (seekEntry.id === EBMLId.Tags) { + segment.tagsSeen = true; + } else if (seekEntry.id === EBMLId.Attachments) { + segment.attachmentsSeen = true; + } + } + } + readContiguousElements(slice: FileSlice) { const startIndex = slice.filePos; @@ -1251,11 +1351,201 @@ export class MatroskaDemuxer extends Demuxer { // We'll offset this by the block's timestamp later this.currentBlock.referencedTimestamps.push(relativeTimestamp); }; break; + + case EBMLId.Tag: { + this.currentTagTargetIsMovie = true; + this.readContiguousElements(slice.slice(dataStartPos, size)); + }; break; + + case EBMLId.Targets: { + this.readContiguousElements(slice.slice(dataStartPos, size)); + }; break; + + case EBMLId.TargetTypeValue: { + const targetTypeValue = readUnsignedInt(slice, size); + if (targetTypeValue !== 50) { + this.currentTagTargetIsMovie = false; + } + }; break; + + case EBMLId.TagTrackUID: + case EBMLId.TagEditionUID: + case EBMLId.TagChapterUID: + case EBMLId.TagAttachmentUID: { + this.currentTagTargetIsMovie = false; + }; break; + + case EBMLId.SimpleTag: { + if (!this.currentTagTargetIsMovie) break; + + this.currentSimpleTagName = null; + this.readContiguousElements(slice.slice(dataStartPos, size)); + }; break; + + case EBMLId.TagName: { + this.currentSimpleTagName = readUnicodeString(slice, size); + }; break; + + case EBMLId.TagString: { + if (!this.currentSimpleTagName) break; + + const value = readUnicodeString(slice, size); + this.processTagValue(this.currentSimpleTagName, value); + }; break; + + case EBMLId.TagBinary: { + if (!this.currentSimpleTagName) break; + + const value = readBytes(slice, size); + this.processTagValue(this.currentSimpleTagName, value); + }; break; + + case EBMLId.AttachedFile: { + if (!this.currentSegment) break; + + this.currentAttachedFile = { + fileName: null, + fileMediaType: null, + fileData: null, + fileDescription: null, + }; + + this.readContiguousElements(slice.slice(dataStartPos, size)); + + // Only process image attachments + if (this.currentAttachedFile.fileMediaType?.startsWith('image/') && this.currentAttachedFile.fileData) { + const fileName = this.currentAttachedFile.fileName; + let kind: 'coverFront' | 'coverBack' | 'unknown' = 'unknown'; + + if (fileName) { + const lowerName = fileName.toLowerCase(); + if (lowerName.startsWith('cover.')) { + kind = 'coverFront'; + } else if (lowerName.startsWith('back.')) { + kind = 'coverBack'; + } + } + + this.currentSegment.metadata.images ??= []; + this.currentSegment.metadata.images.push({ + data: this.currentAttachedFile.fileData, + mimeType: this.currentAttachedFile.fileMediaType, + kind, + name: this.currentAttachedFile.fileName ?? undefined, + description: this.currentAttachedFile.fileDescription ?? undefined, + }); + } + + this.currentAttachedFile = null; + }; break; + + case EBMLId.FileName: { + if (!this.currentAttachedFile) break; + + this.currentAttachedFile.fileName = readUnicodeString(slice, size); + }; break; + + case EBMLId.FileMediaType: { + if (!this.currentAttachedFile) break; + + this.currentAttachedFile.fileMediaType = readAsciiString(slice, size); + }; break; + + case EBMLId.FileData: { + if (!this.currentAttachedFile) break; + + this.currentAttachedFile.fileData = readBytes(slice, size); + }; break; + + case EBMLId.FileDescription: { + if (!this.currentAttachedFile) break; + + this.currentAttachedFile.fileDescription = readUnicodeString(slice, size); + }; break; } slice.filePos = dataStartPos + size; return true; } + + processTagValue(name: string, value: string | Uint8Array) { + if (!this.currentSegment?.metadata) return; + + const metadata = this.currentSegment.metadata; + metadata.raw ??= {}; + metadata.raw[name] = value; + + if (typeof value === 'string') { + switch (name.toLowerCase()) { + case 'title': { + metadata.title ??= value; + }; break; + + case 'description': { + metadata.description ??= value; + }; break; + + case 'artist': { + metadata.artist ??= value; + }; break; + + case 'album': { + metadata.album ??= value; + }; break; + + case 'album_artist': { + metadata.albumArtist ??= value; + }; break; + + case 'genre': { + metadata.genre ??= value; + }; break; + + case 'comment': { + metadata.comment ??= value; + }; break; + + case 'lyrics': { + metadata.lyrics ??= value; + }; break; + + case 'date': { + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + metadata.date ??= date; + } + }; break; + + case 'track_number': + case 'part_number': { + const parts = value.split('/'); + const trackNum = Number.parseInt(parts[0]!, 10); + const trackNumMax = parts[1] && Number.parseInt(parts[1], 10); + + if (Number.isInteger(trackNum) && trackNum > 0) { + metadata.trackNumber ??= trackNum; + } + if (trackNumMax && Number.isInteger(trackNumMax) && trackNumMax > 0) { + metadata.trackNumberMax ??= trackNumMax; + } + }; break; + + case 'disc_number': + case 'disc': { + const discParts = value.split('/'); + const discNum = Number.parseInt(discParts[0]!, 10); + const discNumMax = discParts[1] && Number.parseInt(discParts[1], 10); + + if (Number.isInteger(discNum) && discNum > 0) { + metadata.discNumber ??= discNum; + } + if (discNumMax && Number.isInteger(discNumMax) && discNumMax > 0) { + metadata.discNumberMax ??= discNumMax; + } + }; break; + } + } + } } abstract class MatroskaTrackBacking implements InputTrackBacking { diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 0e46954..f6fa690 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -13,7 +13,10 @@ import { TRANSFER_CHARACTERISTICS_MAP, UNDETERMINED_LANGUAGE, assert, + assertNever, colorSpaceIsComplete, + imageMimeTypeToExtension, + keyValueIterator, normalizeRotation, promiseWithResolvers, roundToMultiple, @@ -74,22 +77,6 @@ type InternalMediaChunk = { additions: Uint8Array | null; }; -type SeekHead = { - id: number; - data: { - id: number; - data: ({ - id: number; - data: Uint8Array; - size?: undefined; - } | { - id: number; - size: number; - data: number; - })[]; - }[]; -}; - type MatroskaTrackData = { chunkQueue: InternalMediaChunk[]; lastWrittenMsTimestamp: number | null; @@ -137,8 +124,10 @@ export class MatroskaMuxer extends Muxer { private segment: EBMLElement | null = null; private segmentInfo: EBMLElement | null = null; - private seekHead: SeekHead | null = null; + private seekHead: EBMLElement | null = null; private tracksElement: EBMLElement | null = null; + private tagsElement: EBMLElement | null = null; + private attachmentsElement: EBMLElement | null = null; private segmentDuration: EBMLElement | null = null; private cues: EBMLElement | null = null; @@ -169,10 +158,6 @@ export class MatroskaMuxer extends Muxer { this.writeEBMLHeader(); - if (!this.format._options.appendOnly) { - this.createSeekHead(); - } - this.createSegmentInfo(); this.createCues(); @@ -207,24 +192,72 @@ export class MatroskaMuxer extends Muxer { * Creates a SeekHead element which is positioned near the start of the file and allows the media player to seek to * relevant sections more easily. Since we don't know the positions of those sections yet, we'll set them later. */ - private createSeekHead() { + private maybeCreateSeekHead(writeOffsets: boolean) { + if (this.format._options.appendOnly) { + return; + } + const kaxCues = new Uint8Array([0x1c, 0x53, 0xbb, 0x6b]); const kaxInfo = new Uint8Array([0x15, 0x49, 0xa9, 0x66]); const kaxTracks = new Uint8Array([0x16, 0x54, 0xae, 0x6b]); + const kaxAttachments = new Uint8Array([0x19, 0x41, 0xa4, 0x69]); + const kaxTags = new Uint8Array([0x12, 0x54, 0xc3, 0x67]); const seekHead = { id: EBMLId.SeekHead, data: [ { id: EBMLId.Seek, data: [ { id: EBMLId.SeekID, data: kaxCues }, - { id: EBMLId.SeekPosition, size: 5, data: 0 }, + { + id: EBMLId.SeekPosition, + size: 5, + data: writeOffsets + ? this.ebmlWriter.offsets.get(this.cues!)! - this.segmentDataOffset + : 0, + }, ] }, { id: EBMLId.Seek, data: [ { id: EBMLId.SeekID, data: kaxInfo }, - { id: EBMLId.SeekPosition, size: 5, data: 0 }, + { + id: EBMLId.SeekPosition, + size: 5, + data: writeOffsets + ? this.ebmlWriter.offsets.get(this.segmentInfo!)! - this.segmentDataOffset + : 0, + }, ] }, { id: EBMLId.Seek, data: [ { id: EBMLId.SeekID, data: kaxTracks }, - { id: EBMLId.SeekPosition, size: 5, data: 0 }, + { + id: EBMLId.SeekPosition, + size: 5, + data: writeOffsets + ? this.ebmlWriter.offsets.get(this.tracksElement!)! - this.segmentDataOffset + : 0, + }, ] }, + this.attachmentsElement + ? { id: EBMLId.Seek, data: [ + { id: EBMLId.SeekID, data: kaxAttachments }, + { + id: EBMLId.SeekPosition, + size: 5, + data: writeOffsets + ? this.ebmlWriter.offsets.get(this.attachmentsElement)! - this.segmentDataOffset + : 0, + }, + ] } + : null, + this.tagsElement + ? { id: EBMLId.Seek, data: [ + { id: EBMLId.SeekID, data: kaxTags }, + { + id: EBMLId.SeekPosition, + size: 5, + data: writeOffsets + ? this.ebmlWriter.offsets.get(this.tagsElement)! - this.segmentDataOffset + : 0, + }, + ] } + : null, ] }; this.seekHead = seekHead; } @@ -379,14 +412,187 @@ export class MatroskaMuxer extends Muxer { ]; } + private maybeCreateTags() { + const simpleTags: EBMLElement[] = []; + + const addSimpleTag = (key: string, value: string | Uint8Array) => { + simpleTags.push({ id: EBMLId.SimpleTag, data: [ + { id: EBMLId.TagName, data: new EBMLUnicodeString(key) }, + typeof value === 'string' + ? { id: EBMLId.TagString, data: new EBMLUnicodeString(value) } + : { id: EBMLId.TagBinary, data: value }, + ] }); + }; + + const metadata = this.output._metadata; + const writtenTags = new Set(); + + for (const { key, value } of keyValueIterator(metadata)) { + switch (key) { + case 'title': { + addSimpleTag('TITLE', value); + writtenTags.add('TITLE'); + }; break; + + case 'description': { + addSimpleTag('DESCRIPTION', value); + writtenTags.add('DESCRIPTION'); + }; break; + + case 'artist': { + addSimpleTag('ARTIST', value); + writtenTags.add('ARTIST'); + }; break; + + case 'album': { + addSimpleTag('ALBUM', value); + writtenTags.add('ALBUM'); + }; break; + + case 'albumArtist': { + addSimpleTag('ALBUM_ARTIST', value); + writtenTags.add('ALBUM_ARTIST'); + }; break; + + case 'genre': { + addSimpleTag('GENRE', value); + writtenTags.add('GENRE'); + }; break; + + case 'comment': { + addSimpleTag('COMMENT', value); + writtenTags.add('COMMENT'); + }; break; + + case 'lyrics': { + addSimpleTag('LYRICS', value); + writtenTags.add('LYRICS'); + }; break; + + case 'date': { + addSimpleTag('DATE', value.toISOString().split('T')[0]!); + writtenTags.add('DATE'); + }; break; + + case 'trackNumber': { + const string = metadata.trackNumberMax !== undefined + ? `${value}/${metadata.trackNumberMax}` + : value.toString(); + + addSimpleTag('PART_NUMBER', string); + writtenTags.add('PART_NUMBER'); + }; break; + + case 'discNumber': { + const string = metadata.discNumberMax !== undefined + ? `${value}/${metadata.discNumberMax}` + : value.toString(); + + addSimpleTag('DISC', string); + writtenTags.add('DISC'); + }; break; + + case 'trackNumberMax': + case 'discNumberMax': { + // Handled with trackNumber and discNumber respectively + }; break; + + case 'images': + case 'raw': { + // Handled elsewhere + }; break; + + default: assertNever(key); + } + } + + if (metadata.raw) { + for (const key in metadata.raw) { + const value = metadata.raw[key]!; + if (value == null || writtenTags.has(key)) { + continue; + } + + if (typeof value === 'string' || value instanceof Uint8Array) { + addSimpleTag(key, value); + } + } + } + + if (simpleTags.length === 0) { + return; + } + + this.tagsElement = { + id: EBMLId.Tags, + data: [{ id: EBMLId.Tag, data: [ + { id: EBMLId.Targets, data: [ + { id: EBMLId.TargetTypeValue, data: 50 }, + { id: EBMLId.TargetType, data: 'MOVIE' }, + ] }, + ...simpleTags, + ] }], + }; + } + + private maybeCreateAttachments() { + const metadata = this.output._metadata; + if (!metadata.images || metadata.images.length === 0) { + return; + } + + const existingFileUids = new Set(); + + this.attachmentsElement = { id: EBMLId.Attachments, data: metadata.images.map((image): EBMLElement => { + let imageName = image.name; + if (imageName === undefined) { + const baseName = image.kind === 'coverFront' ? 'cover' : image.kind === 'coverBack' ? 'back' : 'image'; + imageName = baseName + (imageMimeTypeToExtension(image.mimeType) ?? ''); + } + + let fileUid: number; + while (true) { + fileUid = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + + if (fileUid !== 0 && !existingFileUids.has(fileUid)) { + break; + } + } + + existingFileUids.add(fileUid); + + return { + id: EBMLId.AttachedFile, + data: [ + image.description !== undefined + ? { id: EBMLId.FileDescription, data: new EBMLUnicodeString(image.description) } + : null, + { id: EBMLId.FileName, data: new EBMLUnicodeString(imageName) }, + { id: EBMLId.FileMediaType, data: image.mimeType }, + { id: EBMLId.FileData, data: image.data }, + { id: EBMLId.FileUID, data: fileUid }, + ], + }; + }) }; + } + private createSegment() { + this.createTracks(); + this.maybeCreateTags(); + this.maybeCreateAttachments(); + this.maybeCreateSeekHead(false); + const segment: EBML = { id: EBMLId.Segment, size: this.format._options.appendOnly ? -1 : SEGMENT_SIZE_BYTES, data: [ - !this.format._options.appendOnly ? this.seekHead as EBML : null, + this.seekHead, // null if append-only this.segmentInfo, this.tracksElement, + // Matroska spec says put this at the end of the file, but I think placing it before the first cluster + // makes more sense, and FFmpeg agrees (argumentum ad ffmpegum fallacy) + this.attachmentsElement, + this.tagsElement, ], }; this.segment = segment; @@ -754,7 +960,6 @@ export class MatroskaMuxer extends Muxer { private writeBlock(trackData: MatroskaTrackData, chunk: InternalMediaChunk) { // Due to the interlacing algorithm, this code will be run once we've seen one chunk from every media track. if (!this.segment) { - this.createTracks(); this.createSegment(); } @@ -956,7 +1161,6 @@ export class MatroskaMuxer extends Muxer { this.allTracksKnown.resolve(); if (!this.segment) { - this.createTracks(); this.createSegment(); } @@ -984,14 +1188,9 @@ export class MatroskaMuxer extends Muxer { this.ebmlWriter.writeEBML(this.segmentDuration); // Fill in SeekHead position data and write it again - this.seekHead!.data[0]!.data[1]!.data - = this.ebmlWriter.offsets.get(this.cues)! - this.segmentDataOffset; - this.seekHead!.data[1]!.data[1]!.data - = this.ebmlWriter.offsets.get(this.segmentInfo!)! - this.segmentDataOffset; - this.seekHead!.data[2]!.data[1]!.data - = this.ebmlWriter.offsets.get(this.tracksElement!)! - this.segmentDataOffset; - - this.writer.seek(this.ebmlWriter.offsets.get(this.seekHead!)!); + assert(this.seekHead); + this.writer.seek(this.ebmlWriter.offsets.get(this.seekHead)!); + this.maybeCreateSeekHead(true); this.ebmlWriter.writeEBML(this.seekHead); this.writer.seek(endPos); diff --git a/src/metadata.ts b/src/metadata.ts index adda62f..9c37005 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -68,8 +68,8 @@ export const validateMediaMetadata = (metadata: MediaMetadata) => { if (metadata.genre !== undefined && typeof metadata.genre !== 'string') { throw new TypeError('metadata.genre, when provided, must be a string.'); } - if (metadata.date !== undefined && !(metadata.date instanceof Date)) { - throw new TypeError('metadata.date, when provided, must be a Date.'); + if (metadata.date !== undefined && (!(metadata.date instanceof Date) || Number.isNaN(metadata.date.getTime()))) { + throw new TypeError('metadata.date, when provided, must be a valid Date.'); } if (metadata.lyrics !== undefined && typeof metadata.lyrics !== 'string') { throw new TypeError('metadata.lyrics, when provided, must be a string.'); diff --git a/src/misc.ts b/src/misc.ts index a94ff73..fcdd4ca 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -675,3 +675,30 @@ export const keyValueIterator = function* >(ob yield { key, value } as KeyValuePair; } }; + +export const imageMimeTypeToExtension = (mimeType: string) => { + switch (mimeType.toLowerCase()) { + case 'image/jpeg': + case 'image/jpg': + return '.jpg'; + case 'image/png': + return '.png'; + case 'image/gif': + return '.gif'; + case 'image/webp': + return '.webp'; + case 'image/bmp': + return '.bmp'; + case 'image/svg+xml': + return '.svg'; + case 'image/tiff': + return '.tiff'; + case 'image/avif': + return '.avif'; + case 'image/x-icon': + case 'image/vnd.microsoft.icon': + return '.ico'; + default: + return null; + } +};