From cdc2ef63dd2ff779279579bbe2207cec8e7897c7 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:32:18 +0200 Subject: [PATCH] Add Logging singleton to manually control Mediabunny's console output (fixes #415) --- docs/api-config.json | 1 + docs/guide/extensions/server.md | 34 ++++++++ packages/aac-encoder/src/index.ts | 4 +- packages/ac3/src/index.ts | 4 +- packages/flac-encoder/src/index.ts | 4 +- packages/mp3-encoder/src/index.ts | 4 +- packages/server/README.md | 34 ++++++++ packages/server/src/index.ts | 3 +- scripts/check-docblocks.ts | 1 + scripts/generate-api-docs.ts | 39 +++++++++- src/codec-data.ts | 13 ++-- src/conversion.ts | 7 +- src/custom-coder.ts | 9 ++- src/hls/hls-muxer.ts | 7 +- src/id3.ts | 7 +- src/index.ts | 9 ++- src/input-track.ts | 5 +- src/isobmff/isobmff-demuxer.ts | 35 +++++---- src/logging.ts | 120 +++++++++++++++++++++++++++++ src/matroska/matroska-demuxer.ts | 3 +- src/media-sink.ts | 3 +- src/media-source.ts | 9 ++- src/misc.ts | 7 +- src/mpeg-ts/mpeg-ts-demuxer.ts | 3 +- src/output.ts | 9 ++- src/sample.ts | 5 +- src/source.ts | 7 +- src/wave/wave-muxer.ts | 3 +- 28 files changed, 324 insertions(+), 65 deletions(-) create mode 100644 src/logging.ts diff --git a/docs/api-config.json b/docs/api-config.json index 453cd8e..376e45e 100644 --- a/docs/api-config.json +++ b/docs/api-config.json @@ -19,6 +19,7 @@ "Encoding": "Encoder configuration and encodability checks.", "Decoding": "Decoder configuration and decodability checks.", "Custom coders": "API for adding custom encoders and decoders.", + "Logging": "Control over what Mediabunny logs to the console.", "Miscellaneous": "Whatever's left.", "@mediabunny/server": "Adds full video/audio decoder and encoder support to Mediabunny running in server-side environments such as Node, Bun, or Deno.", diff --git a/docs/guide/extensions/server.md b/docs/guide/extensions/server.md index 177b5ea..3ed3663 100644 --- a/docs/guide/extensions/server.md +++ b/docs/guide/extensions/server.md @@ -275,6 +275,40 @@ return new VideoSample(pixels, { }); ``` +## Controlling logging + +Especially in command-line applications you usually don't want Mediabunny interfering with your stdout and stderr output. Mediabunny provides ways to control its console output: +```ts +import { Logging, LogLevel } from 'mediabunny'; + +// The default: Mediabunny can log errors, warnings, and information messages. +Logging.level = LogLevel.Info; + +// Only log warnings and errors. +Logging.level = LogLevel.Warnings; + +// Only log errors. +Logging.level = LogLevel.Errors; + +// Don't log anything at all. +Logging.level = LogLevel.Silent; +``` + +You can also hook into log events: +```ts +Logging.on('error', (args: unknown[]) => { + // Handle error message +}); + +Logging.on('warn', (args: unknown[]) => { + // Handle warning message +}); + +Logging.on('info', (args: unknown[]) => { + // Handle info message +}); +``` + ## Implementation details `@mediabunny/server` uses [NodeAV](https://github.com/seydx/node-av) under the hood which provides N-API C bindings to FFmpeg's C API. Using NodeAV, this package implements [custom decoders and encoders](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) by directly using the APIs provided by `libavcodec`. diff --git a/packages/aac-encoder/src/index.ts b/packages/aac-encoder/src/index.ts index d0bed47..f1c0d5b 100644 --- a/packages/aac-encoder/src/index.ts +++ b/packages/aac-encoder/src/index.ts @@ -6,9 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logging } from 'mediabunny'; + const AAC_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/aac-encoder loaded'); if ((globalThis as Record)[AAC_ENCODER_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/aac-encoder was loaded twice.' + ' This will likely cause the encoder not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/aac-encoder,' diff --git a/packages/ac3/src/index.ts b/packages/ac3/src/index.ts index 79ebf7f..cc136af 100644 --- a/packages/ac3/src/index.ts +++ b/packages/ac3/src/index.ts @@ -6,9 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logging } from 'mediabunny'; + const AC3_LOADED_SYMBOL = Symbol.for('@mediabunny/ac3 loaded'); if ((globalThis as Record)[AC3_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/ac3 was loaded twice.' + ' This will likely cause the encoder/decoder not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/ac3,' diff --git a/packages/flac-encoder/src/index.ts b/packages/flac-encoder/src/index.ts index 3a86463..0e3e97e 100644 --- a/packages/flac-encoder/src/index.ts +++ b/packages/flac-encoder/src/index.ts @@ -6,9 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { Logging } from 'mediabunny'; + const FLAC_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/flac-encoder loaded'); if ((globalThis as Record)[FLAC_ENCODER_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/flac-encoder was loaded twice.' + ' This will likely cause the encoder not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/flac-encoder,' diff --git a/packages/mp3-encoder/src/index.ts b/packages/mp3-encoder/src/index.ts index fdea91a..50023b2 100644 --- a/packages/mp3-encoder/src/index.ts +++ b/packages/mp3-encoder/src/index.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny'; +import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, Logging, registerEncoder } from 'mediabunny'; import { MP3_FRAME_HEADER_SIZE, readMp3FrameHeader, SAMPLING_RATES } from '../../../shared/mp3-misc'; import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared'; // @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand @@ -14,7 +14,7 @@ import createWorker from './encode.worker'; const MP3_ENCODER_LOADED_SYMBOL = Symbol.for('@mediabunny/mp3-encoder loaded'); if ((globalThis as Record)[MP3_ENCODER_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/mp3-encoder was loaded twice.' + ' This will likely cause the encoder not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/mp3-encoder,' diff --git a/packages/server/README.md b/packages/server/README.md index 99e7c20..9d9d3f3 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -279,6 +279,40 @@ return new VideoSample(pixels, { }); ``` +## Controlling logging + +Especially in command-line applications you usually don't want Mediabunny interfering with your stdout and stderr output. Mediabunny provides ways to control its console output: +```ts +import { Logging, LogLevel } from 'mediabunny'; + +// The default: Mediabunny can log errors, warnings, and information messages. +Logging.level = LogLevel.Info; + +// Only log warnings and errors. +Logging.level = LogLevel.Warnings; + +// Only log errors. +Logging.level = LogLevel.Errors; + +// Don't log anything at all. +Logging.level = LogLevel.None; +``` + +You can also hook into log events: +```ts +Logging.on('error', (args: unknown[]) => { + // Handle error message +}); + +Logging.on('warn', (args: unknown[]) => { + // Handle warning message +}); + +Logging.on('info', (args: unknown[]) => { + // Handle info message +}); +``` + ## Implementation details `@mediabunny/server` uses [NodeAV](https://github.com/seydx/node-av) under the hood which provides N-API C bindings to FFmpeg's C API. Using NodeAV, this package implements [custom decoders and encoders](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) by directly using the APIs provided by `libavcodec`. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 890bbd9..818b1dd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -8,6 +8,7 @@ import { AudioSample, + Logging, MaybePromise, registerDecoder, registerEncoder, @@ -24,7 +25,7 @@ import { copyAudioSampleToAvFrame, AvFrameAudioSampleResource } from './audio-sa const SERVER_LOADED_SYMBOL = Symbol.for('@mediabunny/server loaded'); if ((globalThis as Record)[SERVER_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\n@mediabunny/server was loaded twice.' + ' This will likely cause the package not to work correctly.' + ' Check if multiple dependencies are importing different versions of @mediabunny/server,' diff --git a/scripts/check-docblocks.ts b/scripts/check-docblocks.ts index 71daa3a..3216afd 100644 --- a/scripts/check-docblocks.ts +++ b/scripts/check-docblocks.ts @@ -23,6 +23,7 @@ const checkDocblocks = (filePath: string) => { || ts.isFunctionDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isEnumDeclaration(node) + || ts.isEnumMember(node) || ts.isPropertySignature(node) || ts.isMethodSignature(node) || ts.isVariableStatement(node) diff --git a/scripts/generate-api-docs.ts b/scripts/generate-api-docs.ts index a0f4347..67cad16 100644 --- a/scripts/generate-api-docs.ts +++ b/scripts/generate-api-docs.ts @@ -683,7 +683,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) } // Check if it's a supported type - if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration)) { + if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration) || ts.isEnumDeclaration(declaration)) { // Supported types - continue processing } else { // Unsupported type - throw error with type info @@ -777,6 +777,43 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) return; } + // Handle enum declarations separately + if (ts.isEnumDeclaration(declaration)) { + const enumName = declaration.name.text; + + const description = extractJsDocDescription(declaration, { + tagHandling: 'filterAll', + transform: text => processLinkTags(text, enumName), + }); + + const order = symbolOrderMap.get(enumName); + if (order === undefined) { + throw new Error(`Symbol '${enumName}' not found in entry files export order`); + } + indexEntries.push({ name: enumName, type: 'Enum', group: groupName, order }); + + // Reconstruct the enum body so each member keeps its value and description + const memberLines = declaration.members.map((member) => { + const memberName = member.name.getText(); + const initializer = member.initializer ? ` = ${member.initializer.getText()}` : ''; + const memberDescription = extractJsDocDescription(member, { + tagHandling: 'filterAll', + transform: text => processLinkTags(text, enumName), + }); + const commentLine = memberDescription ? `\t/** ${memberDescription} */\n` : ''; + return `${commentLine}\t${memberName}${initializer},`; + }); + + const enumDefinition = `enum ${enumName} {\n${memberLines.join('\n')}\n}`; + const deprecationNotice = getDeprecationNotice(declaration, enumName); + let markdown = `${buildFrontmatter(description)}\n\n\n\n# ${enumName}\n\n${deprecationNotice}${description ? `${description}\n\n` : ''}`; + markdown += `\`\`\`ts\n${enumDefinition}\n\`\`\``; + + generatedDocs.set(enumName, markdown); + + return; + } + { const className = declaration.name.text; const isAbstract = ts.isClassDeclaration(declaration) && declaration.modifiers?.some(mod => mod.kind === ts.SyntaxKind.AbstractKeyword); diff --git a/src/codec-data.ts b/src/codec-data.ts index 8da5610..f264c19 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -26,6 +26,7 @@ import { isChromium, setUint24, } from './misc'; +import { Logging } from './logging'; import { PacketType } from './packet'; import { MetadataTags } from './metadata'; import { AC3_SAMPLE_RATES, EAC3_REDUCED_SAMPLE_RATES } from '../shared/ac3-misc'; @@ -318,7 +319,7 @@ export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array): Av sequenceParameterSetExt: hasExtendedData ? spsExtUnits : null, }; } catch (error) { - console.error('Error building AVC Decoder Configuration Record:', error); + Logging._error('Error building AVC Decoder Configuration Record:', error); return null; } }; @@ -478,7 +479,7 @@ export const deserializeAvcDecoderConfigurationRecord = (data: Uint8Array): AvcD return record; } catch (error) { - console.error('Error deserializing AVC Decoder Configuration Record:', error); + Logging._error('Error deserializing AVC Decoder Configuration Record:', error); return null; } }; @@ -795,7 +796,7 @@ export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => { maxDecFrameBuffering, }; } catch (error) { - console.error('Error parsing AVC SPS:', error); + Logging._error('Error parsing AVC SPS:', error); return null; } }; @@ -1044,7 +1045,7 @@ export const parseHevcSps = (sps: Uint8Array): HevcSpsInfo | null => { minSpatialSegmentationIdc, }; } catch (error) { - console.error('Error parsing HEVC SPS:', error); + Logging._error('Error parsing HEVC SPS:', error); return null; } }; @@ -1176,7 +1177,7 @@ export const extractHevcDecoderConfigurationRecord = (packetData: Uint8Array) => return record; } catch (error) { - console.error('Error building HEVC Decoder Configuration Record:', error); + Logging._error('Error building HEVC Decoder Configuration Record:', error); return null; } }; @@ -1612,7 +1613,7 @@ export const deserializeHevcDecoderConfigurationRecord = (data: Uint8Array): Hev arrays, }; } catch (error) { - console.error('Error deserializing HEVC Decoder Configuration Record:', error); + Logging._error('Error deserializing HEVC Decoder Configuration Record:', error); return null; } }; diff --git a/src/conversion.ts b/src/conversion.ts index 49bee67..6f1dc01 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -23,6 +23,7 @@ import { } from './encode'; import { Input } from './input'; import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track'; +import { Logging } from './logging'; import { AudioSampleSink, EncodedPacketSink, @@ -958,7 +959,7 @@ export class Conversion { } if (warnElements.length > 0) { - console.warn(...warnElements); + Logging._warn(...warnElements); } } } @@ -1122,7 +1123,7 @@ export class Conversion { } if (this._canceled) { - console.warn('Conversion already canceled.'); + Logging._warn('Conversion already canceled.'); return; } @@ -1327,7 +1328,7 @@ export class Conversion { firstSample.close(); await tempOutput.finalize(); } catch (error) { - console.info('Error when probing encoder support. Falling back to rerender path.', error); + Logging._info('Error when probing encoder support. Falling back to rerender path.', error); needsRerender = true; void tempOutput.cancel(); } diff --git a/src/custom-coder.ts b/src/custom-coder.ts index 3661d3a..0152876 100644 --- a/src/custom-coder.ts +++ b/src/custom-coder.ts @@ -9,6 +9,7 @@ import { AudioCodec, VideoCodec } from './codec'; import { canDecodeAudioMemo, canDecodeVideoMemo } from './decode'; import { canEncodeAudioMemo, canEncodeVideoMemo } from './encode'; +import { Logging } from './logging'; import { MaybePromise } from './misc'; import { EncodedPacket } from './packet'; import { AudioSample, VideoSample } from './sample'; @@ -149,7 +150,7 @@ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof Cust const casted = decoder as typeof CustomVideoDecoder; if (customVideoDecoders.includes(casted)) { - console.warn('Video decoder already registered.'); + Logging._warn('Video decoder already registered.'); return; } @@ -159,7 +160,7 @@ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof Cust const casted = decoder as typeof CustomAudioDecoder; if (customAudioDecoders.includes(casted)) { - console.warn('Audio decoder already registered.'); + Logging._warn('Audio decoder already registered.'); return; } @@ -181,7 +182,7 @@ export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof Cust const casted = encoder as typeof CustomVideoEncoder; if (customVideoEncoders.includes(casted)) { - console.warn('Video encoder already registered.'); + Logging._warn('Video encoder already registered.'); return; } @@ -191,7 +192,7 @@ export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof Cust const casted = encoder as typeof CustomAudioEncoder; if (customAudioEncoders.includes(casted)) { - console.warn('Audio encoder already registered.'); + Logging._warn('Audio encoder already registered.'); return; } diff --git a/src/hls/hls-muxer.ts b/src/hls/hls-muxer.ts index fb2e39e..5f6baec 100644 --- a/src/hls/hls-muxer.ts +++ b/src/hls/hls-muxer.ts @@ -7,6 +7,7 @@ */ import { MediaCodec, validateAudioChunkMetadata, validateVideoChunkMetadata } from '../codec'; +import { Logging } from '../logging'; import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../media-source'; import { arrayArgmax, @@ -197,7 +198,7 @@ export class HlsMuxer extends Muxer { if (track.type === otherTrack.type) { if (!illegalPairingDetected) { - console.warn( + Logging._warn( `Illegal pairing of two ${track.type} tracks detected, which is not possible in HLS;` + ` treating them as unpaired.`, ); @@ -213,7 +214,7 @@ export class HlsMuxer extends Muxer { || (otherTrack.isVideoTrack() && otherTrack.metadata.hasOnlyKeyPackets) ) { if (!keyPacketsOnlyPairingWarned) { - console.warn( + Logging._warn( `A key-packets-only video track is pairable with another track, which is not` + ` possible in HLS; treating them as unpaired.`, ); @@ -1370,7 +1371,7 @@ export class HlsMuxer extends Muxer { masterPlaylistText += `#EXT-X-MEDIA:TYPE=${type.toUpperCase()},GROUP-ID="${decl.groupId}"`; if (name !== null && /[\n\r"]/.test(name)) { - console.warn( + Logging._warn( 'Dropping track name since it includes a line feed, carriage return, or double quote' + ' character, which are not allowed in HLS playlist attributes.', ); diff --git a/src/id3.ts b/src/id3.ts index bc2d8fa..4354496 100644 --- a/src/id3.ts +++ b/src/id3.ts @@ -7,6 +7,7 @@ */ import { decodeSynchsafe, encodeSynchsafe } from '../shared/mp3-misc'; +import { Logging } from './logging'; import { MetadataTags } from './metadata'; import { coalesceIndex, @@ -165,7 +166,7 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad // https://id3.org/id3v2.3.0 if (![2, 3, 4].includes(header.majorVersion)) { - console.warn(`Unsupported ID3v2 major version: ${header.majorVersion}`); + Logging._warn(`Unsupported ID3v2 major version: ${header.majorVersion}`); return; } @@ -214,13 +215,13 @@ export const parseId3V2Tag = (slice: FileSlice, header: Id3V2Header, tags: Metad } if (frameEncrypted) { - console.warn(`Skipping encrypted ID3v2 frame ${frame.id}`); + Logging._warn(`Skipping encrypted ID3v2 frame ${frame.id}`); reader.pos = frameEndPos; continue; } if (frameCompressed) { - console.warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk + Logging._warn(`Skipping compressed ID3v2 frame ${frame.id}`); // Maybe someday? Idk reader.pos = frameEndPos; continue; } diff --git a/src/index.ts b/src/index.ts index f608e72..1e41cd4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,9 +9,11 @@ /// /// +import { Logging } from './logging'; + const MEDIABUNNY_LOADED_SYMBOL = Symbol.for('mediabunny loaded'); if ((globalThis as Record)[MEDIABUNNY_LOADED_SYMBOL]) { - console.error( + Logging._error( '[WARNING]\nMediabunny was loaded twice.' + ' This will likely cause Mediabunny not to work correctly.' + ' Check if multiple dependencies are importing different versions of Mediabunny,' @@ -151,6 +153,11 @@ export { type FilePath, type MaybePromise, } from './misc'; +export { + Logging, + LogLevel, + type LoggingEvents, +} from './logging'; export { type PsshBox, } from './isobmff/isobmff-misc'; diff --git a/src/input-track.ts b/src/input-track.ts index 035fba2..49965bf 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -10,6 +10,7 @@ import { AudioCodec, MediaCodec, VideoCodec } from './codec'; import { determineVideoPacketType } from './codec-data'; import { customAudioDecoders, customVideoDecoders } from './custom-coder'; import { Input } from './input'; +import { Logging } from './logging'; import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink'; import { assert, MaybePromise, Rational, Rotation, roundToDivisor, simplifyRational } from './misc'; import { TrackType } from './output'; @@ -763,7 +764,7 @@ export class InputVideoTrack extends InputTrack { const support = await VideoDecoder.isConfigSupported(decoderConfig); return support.supported === true; } catch (error) { - console.error('Error during decodability check:', error); + Logging._error('Error during decodability check:', error); return false; } } @@ -903,7 +904,7 @@ export class InputAudioTrack extends InputTrack { return support.supported === true; } } catch (error) { - console.error('Error during decodability check:', error); + Logging._error('Error during decodability check:', error); return false; } } diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index dac56e5..6228ed0 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -94,6 +94,7 @@ import { DEFAULT_TRACK_DISPOSITION, MetadataTags, RichImageData, TrackDispositio import { AC3_SAMPLE_RATES } from '../../shared/ac3-misc'; import { Bitstream } from '../../shared/bitstream'; import { Aes128CbcContext } from '../aes'; +import { Logging } from '../logging'; type InternalTrack = { id: number; @@ -925,7 +926,7 @@ export class IsobmffDemuxer extends Demuxer { } if (relevantEntryFound) { - console.warn( + Logging._warn( 'Unsupported edit list: multiple edits are not currently supported. Only using first edit.', ); break; @@ -937,7 +938,7 @@ export class IsobmffDemuxer extends Demuxer { } if (mediaRate !== 1) { - console.warn('Unsupported edit list entry: media rate must be 1.'); + Logging._warn('Unsupported edit list entry: media rate must be 1.'); break; } @@ -1093,9 +1094,9 @@ export class IsobmffDemuxer extends Demuxer { } else if (codecName === 'av01') { track.info.codec = 'av1'; } else if (codecName === null) { - console.warn(`Unknown encrypted video codec due to missing frma box.`); + Logging._warn(`Unknown encrypted video codec due to missing frma box.`); } else { - console.warn(`Unsupported video codec (sample entry type '${sampleBoxInfo.name}').`); + Logging._warn(`Unsupported video codec (sample entry type '${sampleBoxInfo.name}').`); } } else { slice.skip(6 * 1 + 2); @@ -1170,7 +1171,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (sampleSize === 16) { track.info.codec = track.info.pcmLittleEndian ? 'pcm-s16' : 'pcm-s16be'; } else { - console.warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`); + Logging._warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`); track.info.codec = null; } } else if (codecName === 'sowt') { @@ -1179,7 +1180,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (sampleSize === 16) { track.info.codec = 'pcm-s16'; } else { - console.warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`); + Logging._warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`); track.info.codec = null; } } else if (codecName === 'raw ') { @@ -1203,7 +1204,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (pcmSampleSize === 32) { track.info.codec = 'pcm-s32'; } else { - console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`); + Logging._warn(`Invalid ipcm sample size ${pcmSampleSize}.`); track.info.codec = null; } } else { @@ -1214,7 +1215,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (pcmSampleSize === 32) { track.info.codec = 'pcm-s32be'; } else { - console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`); + Logging._warn(`Invalid ipcm sample size ${pcmSampleSize}.`); track.info.codec = null; } } @@ -1227,7 +1228,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (pcmSampleSize === 64) { track.info.codec = 'pcm-f64'; } else { - console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`); + Logging._warn(`Invalid fpcm sample size ${pcmSampleSize}.`); track.info.codec = null; } } else { @@ -1236,7 +1237,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (pcmSampleSize === 64) { track.info.codec = 'pcm-f64be'; } else { - console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`); + Logging._warn(`Invalid fpcm sample size ${pcmSampleSize}.`); track.info.codec = null; } } @@ -1271,12 +1272,12 @@ export class IsobmffDemuxer extends Demuxer { } if (track.info.codec === null) { - console.warn('Unsupported PCM format.'); + Logging._warn('Unsupported PCM format.'); } } else if (codecName === null) { - console.warn(`Unknown encrypted audio codec due to missing frma box.`); + Logging._warn(`Unknown encrypted audio codec due to missing frma box.`); } else { - console.warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`); + Logging._warn(`Unsupported audio codec (sample entry type '${sampleBoxInfo.name}').`); } } @@ -1317,7 +1318,7 @@ export class IsobmffDemuxer extends Demuxer { defaultSkipByteBlock: null, }; } else { - console.warn(`Unsupported encryption scheme '${schemeType}'.`); + Logging._warn(`Unsupported encryption scheme '${schemeType}'.`); } }; break; @@ -1544,7 +1545,7 @@ export class IsobmffDemuxer extends Demuxer { } else if (objectTypeIndication === 0xdd) { track.info.codec = 'vorbis'; // "nonstandard, gpac uses it" - FFmpeg } else { - console.warn( + Logging._warn( `Unsupported audio codec (objectTypeIndication ${objectTypeIndication}) - discarding track.`, ); } @@ -1730,7 +1731,7 @@ export class IsobmffDemuxer extends Demuxer { const config = parseEac3Config(bytes); if (!config) { - console.warn('Invalid dec3 box contents, ignoring.'); + Logging._warn('Invalid dec3 box contents, ignoring.'); break; } @@ -2377,7 +2378,7 @@ export class IsobmffDemuxer extends Demuxer { break; } if (entryCount > 1) { - console.warn('Multiple saio entries are not supported; using the first offset only.'); + Logging._warn('Multiple saio entries are not supported; using the first offset only.'); } let offset = version === 0 ? readU32Be(slice) : Number(readU64Be(slice)); diff --git a/src/logging.ts b/src/logging.ts new file mode 100644 index 0000000..a5c6e3c --- /dev/null +++ b/src/logging.ts @@ -0,0 +1,120 @@ +/*! + * Copyright (c) 2026-present, Vanilagy and contributors + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +import { EventEmitter, type EventListenerOptions } from './misc'; + +/** + * Controls how much information Mediabunny prints to the console. Higher levels include all lower levels. + * + * @group Logging + * @public + */ +export enum LogLevel { + /** Nothing is printed to the console. */ + Silent = 0, + /** Only errors are printed. */ + Errors = 1, + /** Errors and warnings are printed. */ + Warnings = 2, + /** Errors, warnings, and informational messages are printed. */ + Info = 3, +} + +/** + * The events emitted by {@link Logging}. Each event carries the same arguments that were passed to the corresponding + * log call. + * + * @group Logging + * @public + */ +export type LoggingEvents = { + /** Emitted before an error is logged. */ + error: unknown[]; + /** Emitted before a warning is logged. */ + warn: unknown[]; + /** Emitted before an informational message is logged. */ + info: unknown[]; +}; + +/** + * Mediabunny's central logging singleton. Use {@link Logging.level} to control how much is printed to the console, + * and subscribe to log events using {@link Logging.on}. + * + * Having manual control over logging is useful for command-line applications where you want full say over the output. + * + * @group Logging + * @public + */ +export class Logging { + private constructor() {} + + /** @internal */ + static _level: LogLevel = LogLevel.Info; + /** @internal */ + static _emitterInstance: EventEmitter | null = null; + + /** The current log level. Defaults to {@link LogLevel.Info}. */ + static get level() { + return Logging._level; + } + + static set level(value: LogLevel) { + if ( + value !== LogLevel.Silent + && value !== LogLevel.Errors + && value !== LogLevel.Warnings + && value !== LogLevel.Info + ) { + throw new TypeError('Invalid log level. Use one of the values of the LogLevel enum.'); + } + + Logging._level = value; + } + + /** @internal */ + static get _emitter() { + // Created lazily to avoid touching the EventEmitter binding at module-eval time + return Logging._emitterInstance ??= new EventEmitter(); + } + + /** Registers a listener for a log event. Returns a function that, when called, removes the listener again. */ + static on( + event: K, + listener: (data: LoggingEvents[K]) => unknown, + options?: EventListenerOptions, + ) { + return Logging._emitter.on(event, listener, options); + } + + /** @internal */ + static _error(...args: unknown[]) { + Logging._emitter._emit('error', args); + + if (Logging._level >= LogLevel.Errors) { + console.error(...args); + } + } + + /** @internal */ + static _warn(...args: unknown[]) { + Logging._emitter._emit('warn', args); + + if (Logging._level >= LogLevel.Warnings) { + console.warn(...args); + } + } + + /** @internal */ + static _info(...args: unknown[]) { + Logging._emitter._emit('info', args); + + if (Logging._level >= LogLevel.Info) { + console.info(...args); + } + } +} diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 8cb7403..a44a40d 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -24,6 +24,7 @@ import { } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; +import { Logging } from '../logging'; import { InputAudioTrackBacking, InputTrackBacking, @@ -1021,7 +1022,7 @@ export class MatroskaDemuxer extends Demuxer { || instruction.scope !== ContentEncodingScope.Block || instruction.data.algorithm !== ContentCompAlgo.HeaderStripping; })) { - console.warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`); + Logging._warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`); this.currentTrack = null; } diff --git a/src/media-sink.ts b/src/media-sink.ts index 36f6174..72466ea 100644 --- a/src/media-sink.ts +++ b/src/media-sink.ts @@ -54,6 +54,7 @@ import { VideoSample, VideoSamplePixelFormat, } from './sample'; +import { Logging } from './logging'; /** * Additional options for controlling packet retrieval. @@ -1324,7 +1325,7 @@ export class ColorAlphaMerger { this.gl = null; this.canvas = null; mergerGpuUnavailable = true; - console.warn('Falling back to CPU for color/alpha merging.', error); + Logging._warn('Falling back to CPU for color/alpha merging.', error); } } } diff --git a/src/media-source.ts b/src/media-source.ts index 59786ca..bb7ade9 100644 --- a/src/media-source.ts +++ b/src/media-source.ts @@ -64,6 +64,7 @@ import { } from './encode'; import { AudioResampler } from './resample'; import { determineVideoPacketType } from './codec-data'; +import { Logging } from './logging'; /** * Base class for media sources. Media sources are used to add media samples to an output file. @@ -968,7 +969,7 @@ export class ColorAlphaSplitter { this.gl = null; this.canvas = null; splitterGpuUnavailable = true; - console.warn('Falling back to CPU for color/alpha splitting.', error); + Logging._warn('Falling back to CPU for color/alpha splitting.', error); } } } @@ -1116,7 +1117,7 @@ export class ColorAlphaSplitter { this.gl.shaderSource(shader, source); this.gl.compileShader(shader); if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) { - console.error('Shader compile error:', this.gl.getShaderInfoLog(shader)); + Logging._error('Shader compile error:', this.gl.getShaderInfoLog(shader)); } return shader; } @@ -1673,7 +1674,7 @@ export class MediaStreamVideoTrackSource extends VideoSource { /** @internal */ override async _start() { if (!this._errorPromiseAccessed) { - console.warn( + Logging._warn( 'Make sure not to ignore the `errorPromise` field on MediaStreamVideoTrackSource, so that any internal' + ' errors get bubbled up properly.', ); @@ -2783,7 +2784,7 @@ export class MediaStreamAudioTrackSource extends AudioSource { /** @internal */ override async _start() { if (!this._errorPromiseAccessed) { - console.warn( + Logging._warn( 'Make sure not to ignore the `errorPromise` field on MediaStreamAudioTrackSource, so that any internal' + ' errors get bubbled up properly.', ); diff --git a/src/misc.ts b/src/misc.ts index ee938d3..6c923df 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -7,6 +7,7 @@ */ import { Bitstream } from '../shared/bitstream'; +import { Logging } from './logging'; export function assert(x: unknown): asserts x { if (!x) { @@ -577,7 +578,7 @@ export const retriedFetch = async ( throw error; } - console.error('Retrying failed fetch. Error:', error); + Logging._error('Retrying failed fetch. Error:', error); if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) { throw new TypeError('Retry delay must be a non-negative finite number.'); @@ -1215,7 +1216,7 @@ export class EventEmitter> { /** @internal */ _listeners = new Map unknown; once: boolean }>>(); - /** Registers a listener for the given event. */ + /** Registers a listener for the given event. Returns a function that, when called, removes the listener again. */ on( event: K, listener: (data: TEvents[K]) => unknown, @@ -1246,6 +1247,8 @@ export class EventEmitter> { try { (entry.fn as (data: unknown) => void)(data); } catch (error) { + // Deliberately not routed through Logging here: Logging emits via an EventEmitter, so a throwing + // log listener would recurse straight back into this handler. console.error(error); } diff --git a/src/mpeg-ts/mpeg-ts-demuxer.ts b/src/mpeg-ts/mpeg-ts-demuxer.ts index 795c0fc..f6c281f 100644 --- a/src/mpeg-ts/mpeg-ts-demuxer.ts +++ b/src/mpeg-ts/mpeg-ts-demuxer.ts @@ -41,6 +41,7 @@ import { } from '../codec-data'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; +import { Logging } from '../logging'; import { InputAudioTrackBacking, InputTrackBacking, @@ -403,7 +404,7 @@ export class MpegTsDemuxer extends Demuxer { // we can't determine its metadata and also have no idea how to packetize its data. if (!ignoredStreamTypes.has(streamType)) { - console.warn( + Logging._warn( `Note: MPEG-TS streams with stream_type 0x${streamType.toString(16)} are not` + ` currently supported.`, ); diff --git a/src/output.ts b/src/output.ts index ecd3862..f6541f4 100644 --- a/src/output.ts +++ b/src/output.ts @@ -13,6 +13,7 @@ import { OutputFormat } from './output-format'; import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source'; import { PathedTarget, Target, TargetRequest } from './target'; import { Writer } from './writer'; +import { Logging } from './logging'; /** * List of all track types. @@ -794,7 +795,7 @@ export class Output< } if (this._startPromise) { - console.warn('Output has already been started.'); + Logging._warn('Output has already been started.'); return this._startPromise; } @@ -831,13 +832,13 @@ export class Output< */ async cancel() { if (this._cancelPromise) { - console.warn('Output has already been canceled.'); + Logging._warn('Output has already been canceled.'); return this._cancelPromise; } else if (this.state === 'finalizing' || this.state === 'finalized') { // Don't wanna warn when finalizing since that shows a warning when finalization fails and then cancel // is called if (this.state === 'finalized') { - console.warn('Output has already been finalized.'); + Logging._warn('Output has already been finalized.'); } return; @@ -872,7 +873,7 @@ export class Output< throw new Error('Cannot finalize after canceling.'); } if (this._finalizePromise) { - console.warn('Output has already been finalized.'); + Logging._warn('Output has already been finalized.'); return this._finalizePromise; } diff --git a/src/sample.ts b/src/sample.ts index d6fa6ef..19cd62b 100644 --- a/src/sample.ts +++ b/src/sample.ts @@ -32,6 +32,7 @@ import { MaybePromise, DeepReadonly, } from './misc'; +import { Logging } from './logging'; polyfillSymbolDispose(); @@ -55,7 +56,7 @@ if (typeof FinalizationRegistry !== 'undefined') { if (value.type === 'video') { if (now - lastVideoGcErrorLog >= 1000) { // This error is annoying but oh so important - console.error( + Logging._error( `A VideoSample was garbage collected without first being closed. For proper resource management,` + ` make sure to call close() on all your VideoSamples as soon as you're done using them.`, ); @@ -68,7 +69,7 @@ if (typeof FinalizationRegistry !== 'undefined') { } } else { if (now - lastAudioGcErrorLog >= 1000) { - console.error( + Logging._error( `An AudioSample was garbage collected without first being closed. For proper resource management,` + ` make sure to call close() on all your AudioSamples as soon as you're done using them.`, ); diff --git a/src/source.ts b/src/source.ts index b8312c4..ea56e37 100644 --- a/src/source.ts +++ b/src/source.ts @@ -28,6 +28,7 @@ import { } from './misc'; import * as nodeAlias from './node'; import { InputDisposedError } from './input'; +import { Logging } from './logging'; polyfillSymbolDispose(); @@ -658,7 +659,7 @@ const DEFAULT_RETRY_DELAY = typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true; if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) { - console.warn( + Logging._warn( `Request will not be retried because a CORS error was suspected due to different origins. You can` + ` modify this behavior by providing your own function for the 'getRetryDelay' option.`, ); @@ -955,7 +956,7 @@ export class UrlSource extends PathedSource { && !(url.pathname.endsWith('.m3u8') || url.pathname.endsWith('.m3u')) ) { if (!warnedOrigins.has(url.origin)) { - console.warn( + Logging._warn( `HTTP server (origin ${url.origin}) did not respond to a range request with 206 Partial` + ' Content, meaning the entire resource will now be downloaded. To enable efficient' + ' media file streaming across a network, please make sure your server supports' @@ -1009,7 +1010,7 @@ export class UrlSource extends PathedSource { const retryDelayInSeconds = this._getRetryDelay(1, error, this._url); if (retryDelayInSeconds !== null) { - console.error('Error while reading response stream. Attempting to resume.', error); + Logging._error('Error while reading response stream. Attempting to resume.', error); await wait(1000 * retryDelayInSeconds); break; diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts index 68b1072..f094646 100644 --- a/src/wave/wave-muxer.ts +++ b/src/wave/wave-muxer.ts @@ -17,6 +17,7 @@ import { WavOutputFormat } from '../output-format'; import { assert, assertNever, isIso88591Compatible, keyValueIterator } from '../misc'; import { MetadataTags, metadataTagsAreEmpty } from '../metadata'; import { Id3V2Writer } from '../id3'; +import { Logging } from '../logging'; export class WaveMuxer extends Muxer { private format: WavOutputFormat; @@ -206,7 +207,7 @@ export class WaveMuxer extends Muxer { const writeInfoTag = (tag: string, value: string) => { if (!isIso88591Compatible(value)) { // No Unicode supported here - console.warn(`Didn't write tag '${tag}' because '${value}' is not ISO 8859-1-compatible.`); + Logging._warn(`Didn't write tag '${tag}' because '${value}' is not ISO 8859-1-compatible.`); return; }