diff --git a/testing/web-platform/meta/webcodecs/audioDecoder-codec-specific.https.any.js.ini b/testing/web-platform/meta/webcodecs/audioDecoder-codec-specific.https.any.js.ini index 88e76a5ba6b90..958d5b8d6da74 100644 --- a/testing/web-platform/meta/webcodecs/audioDecoder-codec-specific.https.any.js.ini +++ b/testing/web-platform/meta/webcodecs/audioDecoder-codec-specific.https.any.js.ini @@ -126,46 +126,6 @@ if os == "android": NOTRUN -[audioDecoder-codec-specific.https.any.worker.html?mp4_raw_aac_no_desc] - expected: - if os == "android": ERROR - [Test isConfigSupported()] - expected: - if os == "android": NOTRUN - - [Test that AudioDecoder.isConfigSupported() returns a parsed configuration] - expected: - if os == "android": NOTRUN - - [Test configure()] - expected: - if os == "android": NOTRUN - - [Verify closed AudioDecoder operations] - expected: - if os == "android": NOTRUN - - [Test decoding] - expected: - if os == "android": NOTRUN - - [Test decoding a with negative timestamp] - expected: - if os == "android": NOTRUN - - [Test decoding after flush] - expected: - if os == "android": NOTRUN - - [Test reset during flush] - expected: - if os == "android": NOTRUN - - [AudioDecoder decodeQueueSize test] - expected: - if os == "android": NOTRUN - - [audioDecoder-codec-specific.https.any.html?opus] expected: if (os == "android") and not debug: [OK, ERROR] diff --git a/testing/web-platform/mozilla/meta/webcodecs/__dir__.ini b/testing/web-platform/mozilla/meta/webcodecs/__dir__.ini new file mode 100644 index 0000000000000..09a5101a6a460 --- /dev/null +++ b/testing/web-platform/mozilla/meta/webcodecs/__dir__.ini @@ -0,0 +1,5 @@ +prefs: [dom.media.webcodecs.enabled:true, dom.media.webcodecs.image-decoder.enabled:true, media.ffmpeg.encoder.enabled:true, media.rvfc.enabled:true] +tags: [webcodecs] +disabled: + if (os == "linux") and (bits == 32): Not implemented +lsan-allowed: [PLDHashTable::MakeEntryHandle, mozilla::RemoteDecoderManagerChild::OpenRemoteDecoderManagerChildForProcess, mozilla::ipc::MessageChannel::MessageChannel, mozilla::layers::GPUVideoImage::GPUVideoImage] diff --git a/testing/web-platform/mozilla/meta/webcodecs/audioDecoder-codec-specific.https.any.js.ini b/testing/web-platform/mozilla/meta/webcodecs/audioDecoder-codec-specific.https.any.js.ini new file mode 100644 index 0000000000000..647ffc52ac99e --- /dev/null +++ b/testing/web-platform/mozilla/meta/webcodecs/audioDecoder-codec-specific.https.any.js.ini @@ -0,0 +1,38 @@ +[audioDecoder-codec-specific.https.any.worker.html?mp4_raw_aac_no_desc] + expected: + if os == "android": ERROR + [Test isConfigSupported()] + expected: + if os == "android": NOTRUN + + [Test that AudioDecoder.isConfigSupported() returns a parsed configuration] + expected: + if os == "android": NOTRUN + + [Test configure()] + expected: + if os == "android": NOTRUN + + [Verify closed AudioDecoder operations] + expected: + if os == "android": NOTRUN + + [Test decoding] + expected: + if os == "android": NOTRUN + + [Test decoding a with negative timestamp] + expected: + if os == "android": NOTRUN + + [Test decoding after flush] + expected: + if os == "android": NOTRUN + + [Test reset during flush] + expected: + if os == "android": NOTRUN + + [AudioDecoder decodeQueueSize test] + expected: + if os == "android": NOTRUN diff --git a/testing/web-platform/mozilla/tests/webcodecs/audioDecoder-codec-specific.https.any.js b/testing/web-platform/mozilla/tests/webcodecs/audioDecoder-codec-specific.https.any.js new file mode 100644 index 0000000000000..3c21e43dcd197 --- /dev/null +++ b/testing/web-platform/mozilla/tests/webcodecs/audioDecoder-codec-specific.https.any.js @@ -0,0 +1,301 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js +// META: variant=?mp4_raw_aac_no_desc + +// By spec, if the description is absent, the bitstream defaults to ADTS format. +// However, this is added to ensure compatibility and handle potential misuse cases. +const MP4_AAC_DATA_NO_DESCRIPTION = { + src: 'sfx-aac.mp4', + config: { + codec: 'mp4a.40.2', + sampleRate: 48000, + numberOfChannels: 1, + }, + chunks: [ + {offset: 44, size: 241}, + {offset: 285, size: 273}, + {offset: 558, size: 251}, + {offset: 809, size: 118}, + {offset: 927, size: 223}, + {offset: 1150, size: 141}, + {offset: 1291, size: 217}, + {offset: 1508, size: 159}, + {offset: 1667, size: 209}, + {offset: 1876, size: 176}, + ], + duration: 21333 +}; + +// Allows mutating `callbacks` after constructing the AudioDecoder, wraps calls +// in t.step(). +function createAudioDecoder(t, callbacks) { + return new AudioDecoder({ + output(frame) { + if (callbacks && callbacks.output) { + t.step(() => callbacks.output(frame)); + } else { + t.unreached_func('unexpected output()'); + } + }, + error(e) { + if (callbacks && callbacks.error) { + t.step(() => callbacks.error(e)); + } else { + t.unreached_func('unexpected error()'); + } + } + }); +} + +// Create a view of an ArrayBuffer. +function view(buffer, {offset, size}) { + return new Uint8Array(buffer, offset, size); +} + +let CONFIG = null; +let CHUNK_DATA = null; +let CHUNKS = null; +promise_setup(async () => { + const data = { + '?mp4_raw_aac_no_desc': MP4_AAC_DATA_NO_DESCRIPTION, + }[location.search]; + + // Don't run any tests if the codec is not supported. + assert_equals("function", typeof AudioDecoder.isConfigSupported); + let supported = false; + try { + const support = await AudioDecoder.isConfigSupported({ + codec: data.config.codec, + sampleRate: data.config.sampleRate, + numberOfChannels: data.config.numberOfChannels + }); + supported = support.supported; + } catch (e) { + } + assert_implements_optional(supported, data.config.codec + ' unsupported'); + + // Fetch the media data and prepare buffers. + const response = await fetch(data.src); + const buf = await response.arrayBuffer(); + + CONFIG = {...data.config}; + if (data.config.description) { + CONFIG.description = view(buf, data.config.description); + } + + CHUNK_DATA = []; + // For PCM, split in chunks of 1200 bytes and compute the rest + if (data.chunks.length == 0) { + let offset = data.offset; + // 1200 is divisible by 2 and 3 and is a plausible packet length + // for PCM: this means that there won't be samples split in two packet + let PACKET_LENGTH = 1200; + let bytesPerSample = 0; + switch (data.config.codec) { + case "pcm-s16": bytesPerSample = 2; break; + case "pcm-s24": bytesPerSample = 3; break; + case "pcm-s32": bytesPerSample = 4; break; + case "pcm-f32": bytesPerSample = 4; break; + default: bytesPerSample = 1; break; + } + while (offset < buf.byteLength) { + let size = Math.min(buf.byteLength - offset, PACKET_LENGTH); + assert_equals(size % bytesPerSample, 0); + CHUNK_DATA.push(view(buf, {offset, size})); + offset += size; + } + data.duration = 1000 * 1000 * PACKET_LENGTH / data.config.sampleRate / bytesPerSample; + } else { + CHUNK_DATA = data.chunks.map((chunk, i) => view(buf, chunk)); + } + + CHUNKS = CHUNK_DATA.map((encodedData, i) => new EncodedAudioChunk({ + type: 'key', + timestamp: i * data.duration, + duration: data.duration, + data: encodedData + })); +}); + +promise_test(t => { + return AudioDecoder.isConfigSupported(CONFIG); +}, 'Test isConfigSupported()'); + +promise_test(t => { + // Define a valid config that includes a hypothetical 'futureConfigFeature', + // which is not yet recognized by the User Agent. + const validConfig = { + ...CONFIG, + futureConfigFeature: 'foo', + }; + + // The UA will evaluate validConfig as being "valid", ignoring the + // `futureConfigFeature` it doesn't recognize. + return AudioDecoder.isConfigSupported(validConfig).then((decoderSupport) => { + // AudioDecoderSupport must contain the following properites. + assert_true(decoderSupport.hasOwnProperty('supported')); + assert_true(decoderSupport.hasOwnProperty('config')); + + // AudioDecoderSupport.config must not contain unrecognized properties. + assert_false(decoderSupport.config.hasOwnProperty('futureConfigFeature')); + + // AudioDecoderSupport.config must contiain the recognized properties. + assert_equals(decoderSupport.config.codec, validConfig.codec); + assert_equals(decoderSupport.config.sampleRate, validConfig.sampleRate); + assert_equals( + decoderSupport.config.numberOfChannels, validConfig.numberOfChannels); + + if (validConfig.description) { + // The description must be copied. + assert_false( + decoderSupport.config.description === validConfig.description, + 'description is unique'); + assert_array_equals( + new Uint8Array(decoderSupport.config.description, 0), + new Uint8Array(validConfig.description, 0), 'description'); + } else { + assert_false( + decoderSupport.config.hasOwnProperty('description'), 'description'); + } + }); +}, 'Test that AudioDecoder.isConfigSupported() returns a parsed configuration'); + +promise_test(async t => { + const decoder = createAudioDecoder(t); + decoder.configure(CONFIG); + assert_equals(decoder.state, 'configured', 'state'); +}, 'Test configure()'); + +promise_test(t => { + const decoder = createAudioDecoder(t); + return testClosedCodec(t, decoder, CONFIG, CHUNKS[0]); +}, 'Verify closed AudioDecoder operations'); + +promise_test(async t => { + const callbacks = {}; + const decoder = createAudioDecoder(t, callbacks); + + let outputs = 0; + callbacks.output = frame => { + outputs++; + frame.close(); + }; + + decoder.configure(CONFIG); + CHUNKS.forEach(chunk => { + decoder.decode(chunk); + }); + + await decoder.flush(); + assert_equals(outputs, CHUNKS.length, 'outputs'); +}, 'Test decoding'); + +promise_test(async t => { + const callbacks = {}; + const decoder = createAudioDecoder(t, callbacks); + + let outputs = 0; + callbacks.output = frame => { + outputs++; + frame.close(); + }; + + decoder.configure(CONFIG); + decoder.decode(new EncodedAudioChunk( + {type: 'key', timestamp: -42, data: CHUNK_DATA[0]})); + + await decoder.flush(); + assert_equals(outputs, 1, 'outputs'); +}, 'Test decoding a with negative timestamp'); + +promise_test(async t => { + const callbacks = {}; + const decoder = createAudioDecoder(t, callbacks); + + let outputs = 0; + callbacks.output = frame => { + outputs++; + frame.close(); + }; + + decoder.configure(CONFIG); + decoder.decode(CHUNKS[0]); + + await decoder.flush(); + assert_equals(outputs, 1, 'outputs'); + + decoder.decode(CHUNKS[0]); + await decoder.flush(); + assert_equals(outputs, 2, 'outputs'); +}, 'Test decoding after flush'); + +promise_test(async t => { + const callbacks = {}; + const decoder = createAudioDecoder(t, callbacks); + + decoder.configure(CONFIG); + decoder.decode(CHUNKS[0]); + decoder.decode(CHUNKS[1]); + const flushDone = decoder.flush(); + + // Wait for the first output, then reset. + let outputs = 0; + await new Promise(resolve => { + callbacks.output = frame => { + outputs++; + assert_equals(outputs, 1, 'outputs'); + decoder.reset(); + frame.close(); + resolve(); + }; + }); + + // Flush should have been synchronously rejected. + await promise_rejects_dom(t, 'AbortError', flushDone); + + assert_equals(outputs, 1, 'outputs'); +}, 'Test reset during flush'); + +promise_test(async t => { + const callbacks = {}; + const decoder = createAudioDecoder(t, callbacks); + + // No decodes yet. + assert_equals(decoder.decodeQueueSize, 0); + + decoder.configure(CONFIG); + + // Still no decodes. + assert_equals(decoder.decodeQueueSize, 0); + + let lastDequeueSize = Infinity; + decoder.ondequeue = () => { + assert_greater_than(lastDequeueSize, 0, "Dequeue event after queue empty"); + assert_greater_than(lastDequeueSize, decoder.decodeQueueSize, + "Dequeue event without decreased queue size"); + lastDequeueSize = decoder.decodeQueueSize; + }; + + for (let chunk of CHUNKS) + decoder.decode(chunk); + + assert_greater_than_equal(decoder.decodeQueueSize, 0); + assert_less_than_equal(decoder.decodeQueueSize, CHUNKS.length); + + await decoder.flush(); + // We can guarantee that all decodes are processed after a flush. + assert_equals(decoder.decodeQueueSize, 0); + // Last dequeue event should fire when the queue is empty. + assert_equals(lastDequeueSize, 0); + + // Reset this to Infinity to track the decline of queue size for this next + // batch of decodes. + lastDequeueSize = Infinity; + + for (let chunk of CHUNKS) + decoder.decode(chunk); + + assert_greater_than_equal(decoder.decodeQueueSize, 0); + decoder.reset(); + assert_equals(decoder.decodeQueueSize, 0); +}, 'AudioDecoder decodeQueueSize test'); diff --git a/testing/web-platform/mozilla/tests/webcodecs/sfx-aac.mp4 b/testing/web-platform/mozilla/tests/webcodecs/sfx-aac.mp4 new file mode 100644 index 0000000000000..c7b3417d9c805 Binary files /dev/null and b/testing/web-platform/mozilla/tests/webcodecs/sfx-aac.mp4 differ diff --git a/testing/web-platform/mozilla/tests/webcodecs/utils.js b/testing/web-platform/mozilla/tests/webcodecs/utils.js new file mode 100644 index 0000000000000..f09334677a6a4 --- /dev/null +++ b/testing/web-platform/mozilla/tests/webcodecs/utils.js @@ -0,0 +1,237 @@ +function make_audio_data(timestamp, channels, sampleRate, frames) { + let data = new Float32Array(frames*channels); + + // This generates samples in a planar format. + for (var channel = 0; channel < channels; channel++) { + let hz = 100 + channel * 50; // sound frequency + let base_index = channel * frames; + for (var i = 0; i < frames; i++) { + let t = (i / sampleRate) * hz * (Math.PI * 2); + data[base_index + i] = Math.sin(t); + } + } + + return new AudioData({ + timestamp: timestamp, + data: data, + numberOfChannels: channels, + numberOfFrames: frames, + sampleRate: sampleRate, + format: "f32-planar", + }); +} + +function makeOffscreenCanvas(width, height, options) { + let canvas = new OffscreenCanvas(width, height); + let ctx = canvas.getContext('2d', options); + ctx.fillStyle = 'rgba(50, 100, 150, 255)'; + ctx.fillRect(0, 0, width, height); + return canvas; +} + +function makeImageBitmap(width, height) { + return makeOffscreenCanvas(width, height).transferToImageBitmap(); +} + +// Gives a chance to pending output and error callbacks to complete before +// resolving. +function endAfterEventLoopTurn() { + return new Promise(resolve => step_timeout(resolve, 0)); +} + +// Returns a codec initialization with callbacks that expected to not be called. +function getDefaultCodecInit(test) { + return { + output: test.unreached_func("unexpected output"), + error: test.unreached_func("unexpected error"), + } +} + +// Checks that codec can be configured, reset, reconfigured, and that incomplete +// or invalid configs throw errors immediately. +function testConfigurations(codec, validConfig, unsupportedCodecsList) { + assert_equals(codec.state, "unconfigured"); + + const requiredConfigPairs = validConfig; + let incrementalConfig = {}; + + for (let key in requiredConfigPairs) { + // Configure should fail while required keys are missing. + assert_throws_js(TypeError, () => { codec.configure(incrementalConfig); }); + incrementalConfig[key] = requiredConfigPairs[key]; + assert_equals(codec.state, "unconfigured"); + } + + // Configure should pass once incrementalConfig meets all requirements. + codec.configure(incrementalConfig); + assert_equals(codec.state, "configured"); + + // We should be able to reconfigure the codec. + codec.configure(incrementalConfig); + assert_equals(codec.state, "configured"); + + let config = incrementalConfig; + + unsupportedCodecsList.forEach(unsupportedCodec => { + // Invalid codecs should fail. + config.codec = unsupportedCodec; + assert_throws_dom('NotSupportedError', () => { + codec.configure(config); + }, unsupportedCodec); + }); + + // The failed configures should not affect the current config. + assert_equals(codec.state, "configured"); + + // Test we can configure after a reset. + codec.reset() + assert_equals(codec.state, "unconfigured"); + + codec.configure(validConfig); + assert_equals(codec.state, "configured"); +} + +// Performs an encode or decode with the provided input, depending on whether +// the passed codec is an encoder or a decoder. +function encodeOrDecodeShouldThrow(codec, input) { + // We are testing encode/decode on codecs in invalid states. + assert_not_equals(codec.state, "configured"); + + if (codec.decode) { + assert_throws_dom("InvalidStateError", + () => codec.decode(input), + "decode"); + } else if (codec.encode) { + // Encoders consume frames, so clone it to be safe. + assert_throws_dom("InvalidStateError", + () => codec.encode(input.clone()), + "encode"); + + } else { + assert_unreached("Codec should have encode or decode function"); + } +} + +// Makes sure that we cannot close, configure, reset, flush, decode or encode a +// closed codec. +function testClosedCodec(test, codec, validconfig, codecInput) { + assert_equals(codec.state, "unconfigured"); + + codec.close(); + assert_equals(codec.state, "closed"); + + assert_throws_dom("InvalidStateError", + () => codec.configure(validconfig), + "configure"); + assert_throws_dom("InvalidStateError", + () => codec.reset(), + "reset"); + assert_throws_dom("InvalidStateError", + () => codec.close(), + "close"); + + encodeOrDecodeShouldThrow(codec, codecInput); + + return promise_rejects_dom(test, 'InvalidStateError', codec.flush(), 'flush'); +} + +// Makes sure we cannot flush, encode or decode with an unconfigured coded, and +// that reset is a valid no-op. +function testUnconfiguredCodec(test, codec, codecInput) { + assert_equals(codec.state, "unconfigured"); + + // Configure() and Close() are valid operations that would transition us into + // a different state. + + // Resetting an unconfigured encoder is a no-op. + codec.reset(); + assert_equals(codec.state, "unconfigured"); + + encodeOrDecodeShouldThrow(codec, codecInput); + + return promise_rejects_dom(test, 'InvalidStateError', codec.flush(), 'flush'); +} + +// Reference values generated by: +// https://fiddle.skia.org/c/f100d4d5f085a9e09896aabcbc463868 + +const kSRGBPixel = [50, 100, 150, 255]; +const kP3Pixel = [62, 99, 146, 255]; +const kRec2020Pixel = [87, 106, 151, 255]; + +const kCanvasOptionsP3Uint8 = { + colorSpace: 'display-p3', + pixelFormat: 'uint8' +}; + +const kImageSettingOptionsP3Uint8 = { + colorSpace: 'display-p3', + storageFormat: 'uint8' +}; + +const kCanvasOptionsRec2020Uint8 = { + colorSpace: 'rec2020', + pixelFormat: 'uint8' +}; + +const kImageSettingOptionsRec2020Uint8 = { + colorSpace: 'rec2020', + storageFormat: 'uint8' +}; + +function testCanvas(ctx, width, height, expected_pixel, imageSetting, assert_compares) { + // The dup getImageData is to workaournd crbug.com/1100233 + let imageData = ctx.getImageData(0, 0, width, height, imageSetting); + let colorData = ctx.getImageData(0, 0, width, height, imageSetting).data; + const kMaxPixelToCheck = 128 * 96; + let step = width * height / kMaxPixelToCheck; + step = Math.round(step); + step = (step < 1) ? 1 : step; + for (let i = 0; i < 4 * width * height; i += (4 * step)) { + assert_compares(colorData[i], expected_pixel[0]); + assert_compares(colorData[i + 1], expected_pixel[1]); + assert_compares(colorData[i + 2], expected_pixel[2]); + assert_compares(colorData[i + 3], expected_pixel[3]); + } +} + +function makeDetachedArrayBuffer() { + const buffer = new ArrayBuffer(10); + const view = new Uint8Array(buffer); + new MessageChannel().port1.postMessage(buffer, [buffer]); + return view; +} + +function isFrameClosed(frame) { + return frame.format == null && frame.codedWidth == 0 && + frame.codedHeight == 0 && frame.displayWidth == 0 && + frame.displayHeight == 0 && frame.codedRect == null && + frame.visibleRect == null; +} + +function testImageBitmapToAndFromVideoFrame( + width, height, expectedPixel, canvasOptions, imageBitmapOptions, + imageSetting) { + let canvas = new OffscreenCanvas(width, height); + let ctx = canvas.getContext('2d', canvasOptions); + ctx.fillStyle = 'rgb(50, 100, 150)'; + ctx.fillRect(0, 0, width, height); + testCanvas(ctx, width, height, expectedPixel, imageSetting, assert_equals); + + return createImageBitmap(canvas, imageBitmapOptions) + .then((fromImageBitmap) => { + let videoFrame = new VideoFrame(fromImageBitmap, {timestamp: 0}); + return createImageBitmap(videoFrame, imageBitmapOptions); + }) + .then((toImageBitmap) => { + let myCanvas = new OffscreenCanvas(width, height); + let myCtx = myCanvas.getContext('2d', canvasOptions); + myCtx.drawImage(toImageBitmap, 0, 0); + let tolerance = 2; + testCanvas( + myCtx, width, height, expectedPixel, imageSetting, + (actual, expected) => { + assert_approx_equals(actual, expected, tolerance); + }); + }); +} diff --git a/testing/web-platform/tests/webcodecs/audioDecoder-codec-specific.https.any.js b/testing/web-platform/tests/webcodecs/audioDecoder-codec-specific.https.any.js index fbef67b918d05..6a4793c62c908 100644 --- a/testing/web-platform/tests/webcodecs/audioDecoder-codec-specific.https.any.js +++ b/testing/web-platform/tests/webcodecs/audioDecoder-codec-specific.https.any.js @@ -2,7 +2,6 @@ // META: script=/webcodecs/utils.js // META: variant=?adts_aac // META: variant=?mp4_aac -// META: variant=?mp4_raw_aac_no_desc // META: variant=?mp3 // META: variant=?opus // META: variant=?pcm_alaw @@ -71,30 +70,6 @@ const MP4_AAC_DATA = { duration: 21333 }; -// By spec, if the description is absent, the bitstream defaults to ADTS format. -// However, this is added to ensure compatibility and handle potential misuse cases. -const MP4_AAC_DATA_NO_DESCRIPTION = { - src: 'sfx-aac.mp4', - config: { - codec: 'mp4a.40.2', - sampleRate: 48000, - numberOfChannels: 1, - }, - chunks: [ - {offset: 44, size: 241}, - {offset: 285, size: 273}, - {offset: 558, size: 251}, - {offset: 809, size: 118}, - {offset: 927, size: 223}, - {offset: 1150, size: 141}, - {offset: 1291, size: 217}, - {offset: 1508, size: 159}, - {offset: 1667, size: 209}, - {offset: 1876, size: 176}, - ], - duration: 21333 -}; - const OPUS_DATA = { src: 'sfx-opus.ogg', config: { @@ -187,7 +162,6 @@ promise_setup(async () => { '?adts_aac': ADTS_AAC_DATA, '?mp3': MP3_DATA, '?mp4_aac': MP4_AAC_DATA, - '?mp4_raw_aac_no_desc': MP4_AAC_DATA_NO_DESCRIPTION, '?opus': OPUS_DATA, '?pcm_alaw': PCM_ALAW_DATA, '?pcm_ulaw': PCM_ULAW_DATA,