From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- testing/web-platform/tests/webcodecs/META.yml | 7 + testing/web-platform/tests/webcodecs/README.md | 160 +++++ .../web-platform/tests/webcodecs/WEB_FEATURES.yml | 3 + .../webcodecs/audio-data-serialization.any.js | 93 +++ .../web-platform/tests/webcodecs/audio-data.any.js | 357 ++++++++++ .../audio-data.crossOriginIsolated.https.any.js | 44 ++ ...o-data.crossOriginIsolated.https.any.js.headers | 2 + .../audio-decoder.crossOriginIsolated.https.any.js | 71 ++ ...ecoder.crossOriginIsolated.https.any.js.headers | 2 + .../tests/webcodecs/audio-decoder.https.any.js | 187 +++++ .../audio-encoder-codec-specific.https.any.js | 280 ++++++++ .../webcodecs/audio-encoder-config.https.any.js | 341 ++++++++++ .../tests/webcodecs/audio-encoder.https.any.js | 627 +++++++++++++++++ .../audioDecoder-codec-specific.https.any.js | 371 ++++++++++ testing/web-platform/tests/webcodecs/av1.mp4 | Bin 0 -> 4019 bytes .../tests/webcodecs/chunk-serialization.any.js | 80 +++ .../tests/webcodecs/encoded-audio-chunk.any.js | 45 ++ ...ed-audio-chunk.crossOriginIsolated.https.any.js | 29 + ...-chunk.crossOriginIsolated.https.any.js.headers | 2 + .../tests/webcodecs/encoded-video-chunk.any.js | 56 ++ ...ed-video-chunk.crossOriginIsolated.https.any.js | 29 + ...-chunk.crossOriginIsolated.https.any.js.headers | 2 + ...unk-serialization.crossAgentCluster.helper.html | 23 + ...hunk-serialization.crossAgentCluster.https.html | 166 +++++ .../tests/webcodecs/four-colors-flip.avif | Bin 0 -> 2528 bytes .../tests/webcodecs/four-colors-flip.gif | Bin 0 -> 2701 bytes ...four-colors-full-range-bt2020-pq-444-10bpc.avif | Bin 0 -> 383 bytes .../four-colors-limited-range-420-8bpc.avif | Bin 0 -> 375 bytes .../four-colors-limited-range-420-8bpc.jpg | Bin 0 -> 1006 bytes .../four-colors-limited-range-420-8bpc.webp | Bin 0 -> 456 bytes .../four-colors-limited-range-422-8bpc.avif | Bin 0 -> 380 bytes .../four-colors-limited-range-444-8bpc.avif | Bin 0 -> 372 bytes .../web-platform/tests/webcodecs/four-colors.avif | Bin 0 -> 375 bytes .../web-platform/tests/webcodecs/four-colors.gif | Bin 0 -> 1376 bytes .../web-platform/tests/webcodecs/four-colors.jpg | Bin 0 -> 1242 bytes .../web-platform/tests/webcodecs/four-colors.mp4 | Bin 0 -> 709 bytes .../web-platform/tests/webcodecs/four-colors.png | Bin 0 -> 1442 bytes .../web-platform/tests/webcodecs/four-colors.webp | Bin 0 -> 78 bytes .../tests/webcodecs/full-cycle-test.https.any.js | 213 ++++++ testing/web-platform/tests/webcodecs/h264.annexb | Bin 0 -> 8940 bytes testing/web-platform/tests/webcodecs/h264.mp4 | Bin 0 -> 9821 bytes testing/web-platform/tests/webcodecs/h265.annexb | Bin 0 -> 7637 bytes testing/web-platform/tests/webcodecs/h265.mp4 | Bin 0 -> 8522 bytes .../tests/webcodecs/idlharness.https.any.js | 61 ++ ...der-disconnect-readable-stream-crash.https.html | 12 + ...image-decoder-image-orientation-none.https.html | 88 +++ .../tests/webcodecs/image-decoder-utils.js | 206 ++++++ .../image-decoder.crossOriginIsolated.https.any.js | 27 + ...ecoder.crossOriginIsolated.https.any.js.headers | 2 + .../tests/webcodecs/image-decoder.https.any.js | 502 ++++++++++++++ testing/web-platform/tests/webcodecs/pattern.png | Bin 0 -> 39650 bytes .../webcodecs/per-frame-qp-encoding.https.any.js | 138 ++++ .../webcodecs/reconfiguring-encoder.https.any.js | 121 ++++ ...erialization.crossAgentCluster.serviceworker.js | 61 ++ testing/web-platform/tests/webcodecs/sfx-aac.mp4 | Bin 0 -> 2867 bytes testing/web-platform/tests/webcodecs/sfx-alaw.wav | Bin 0 -> 10332 bytes testing/web-platform/tests/webcodecs/sfx-mulaw.wav | Bin 0 -> 10332 bytes testing/web-platform/tests/webcodecs/sfx-opus.ogg | Bin 0 -> 3244 bytes testing/web-platform/tests/webcodecs/sfx.adts | Bin 0 -> 2078 bytes testing/web-platform/tests/webcodecs/sfx.mp3 | Bin 0 -> 3213 bytes .../webcodecs/temporal-svc-encoding.https.any.js | 105 +++ .../tests/webcodecs/transfering.https.any.js | 281 ++++++++ testing/web-platform/tests/webcodecs/utils.js | 237 +++++++ .../video-decoder.crossOriginIsolated.https.any.js | 68 ++ ...ecoder.crossOriginIsolated.https.any.js.headers | 2 + .../tests/webcodecs/video-decoder.https.any.js | 159 +++++ .../webcodecs/video-encoder-config.https.any.js | 279 ++++++++ .../video-encoder-content-hint.https.any.js | 21 + .../webcodecs/video-encoder-flush.https.any.js | 47 ++ .../webcodecs/video-encoder-h264.https.any.js | 69 ++ .../tests/webcodecs/video-encoder-utils.js | 103 +++ .../tests/webcodecs/video-encoder.https.any.js | 308 +++++++++ .../webcodecs/video-frame-serialization.any.js | 139 ++++ .../tests/webcodecs/videoColorSpace.any.js | 47 ++ .../videoDecoder-codec-specific.https.any.js | 615 +++++++++++++++++ .../tests/webcodecs/videoFrame-alpha.any.js | 50 ++ .../webcodecs/videoFrame-canvasImageSource.html | 160 +++++ .../tests/webcodecs/videoFrame-construction.any.js | 753 +++++++++++++++++++++ ...e-construction.crossOriginIsolated.https.any.js | 11 + ...uction.crossOriginIsolated.https.any.js.headers | 3 + ...eoFrame-construction.crossOriginSource.sub.html | 219 ++++++ .../webcodecs/videoFrame-construction.window.js | 25 + .../tests/webcodecs/videoFrame-copyTo.any.js | 312 +++++++++ ...eoFrame-copyTo.crossOriginIsolated.https.any.js | 18 + ...copyTo.crossOriginIsolated.https.any.js.headers | 2 + .../webcodecs/videoFrame-createImageBitmap.any.js | 28 + .../videoFrame-createImageBitmap.https.any.js | 84 +++ .../tests/webcodecs/videoFrame-drawImage.any.js | 104 +++ .../tests/webcodecs/videoFrame-odd-size.any.js | 50 ++ ...ame-serialization.crossAgentCluster.helper.html | 23 + ...rame-serialization.crossAgentCluster.https.html | 70 ++ .../webcodecs/videoFrame-serialization.https.html | 259 +++++++ .../tests/webcodecs/videoFrame-texImage.any.js | 141 ++++ .../tests/webcodecs/videoFrame-utils.js | 118 ++++ testing/web-platform/tests/webcodecs/vp8.webm | Bin 0 -> 12230 bytes testing/web-platform/tests/webcodecs/vp9.mp4 | Bin 0 -> 6159 bytes .../tests/webcodecs/webgl-test-utils.js | 321 +++++++++ 97 files changed, 9639 insertions(+) create mode 100644 testing/web-platform/tests/webcodecs/META.yml create mode 100644 testing/web-platform/tests/webcodecs/README.md create mode 100644 testing/web-platform/tests/webcodecs/WEB_FEATURES.yml create mode 100644 testing/web-platform/tests/webcodecs/audio-data-serialization.any.js create mode 100644 testing/web-platform/tests/webcodecs/audio-data.any.js create mode 100644 testing/web-platform/tests/webcodecs/audio-data.crossOriginIsolated.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/audio-data.crossOriginIsolated.https.any.js.headers create mode 100644 testing/web-platform/tests/webcodecs/audio-decoder.crossOriginIsolated.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/audio-decoder.crossOriginIsolated.https.any.js.headers create mode 100644 testing/web-platform/tests/webcodecs/audio-decoder.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/audio-encoder-codec-specific.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/audio-encoder-config.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/audio-encoder.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/audioDecoder-codec-specific.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/av1.mp4 create mode 100644 testing/web-platform/tests/webcodecs/chunk-serialization.any.js create mode 100644 testing/web-platform/tests/webcodecs/encoded-audio-chunk.any.js create mode 100644 testing/web-platform/tests/webcodecs/encoded-audio-chunk.crossOriginIsolated.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/encoded-audio-chunk.crossOriginIsolated.https.any.js.headers create mode 100644 testing/web-platform/tests/webcodecs/encoded-video-chunk.any.js create mode 100644 testing/web-platform/tests/webcodecs/encoded-video-chunk.crossOriginIsolated.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/encoded-video-chunk.crossOriginIsolated.https.any.js.headers create mode 100644 testing/web-platform/tests/webcodecs/encodedVideoChunk-serialization.crossAgentCluster.helper.html create mode 100644 testing/web-platform/tests/webcodecs/encodedVideoChunk-serialization.crossAgentCluster.https.html create mode 100644 testing/web-platform/tests/webcodecs/four-colors-flip.avif create mode 100644 testing/web-platform/tests/webcodecs/four-colors-flip.gif create mode 100644 testing/web-platform/tests/webcodecs/four-colors-full-range-bt2020-pq-444-10bpc.avif create mode 100644 testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.avif create mode 100644 testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.jpg create mode 100644 testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.webp create mode 100644 testing/web-platform/tests/webcodecs/four-colors-limited-range-422-8bpc.avif create mode 100644 testing/web-platform/tests/webcodecs/four-colors-limited-range-444-8bpc.avif create mode 100644 testing/web-platform/tests/webcodecs/four-colors.avif create mode 100644 testing/web-platform/tests/webcodecs/four-colors.gif create mode 100644 testing/web-platform/tests/webcodecs/four-colors.jpg create mode 100644 testing/web-platform/tests/webcodecs/four-colors.mp4 create mode 100644 testing/web-platform/tests/webcodecs/four-colors.png create mode 100644 testing/web-platform/tests/webcodecs/four-colors.webp create mode 100644 testing/web-platform/tests/webcodecs/full-cycle-test.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/h264.annexb create mode 100644 testing/web-platform/tests/webcodecs/h264.mp4 create mode 100644 testing/web-platform/tests/webcodecs/h265.annexb create mode 100644 testing/web-platform/tests/webcodecs/h265.mp4 create mode 100644 testing/web-platform/tests/webcodecs/idlharness.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/image-decoder-disconnect-readable-stream-crash.https.html create mode 100644 testing/web-platform/tests/webcodecs/image-decoder-image-orientation-none.https.html create mode 100644 testing/web-platform/tests/webcodecs/image-decoder-utils.js create mode 100644 testing/web-platform/tests/webcodecs/image-decoder.crossOriginIsolated.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/image-decoder.crossOriginIsolated.https.any.js.headers create mode 100644 testing/web-platform/tests/webcodecs/image-decoder.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/pattern.png create mode 100644 testing/web-platform/tests/webcodecs/per-frame-qp-encoding.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/reconfiguring-encoder.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/serialization.crossAgentCluster.serviceworker.js create mode 100644 testing/web-platform/tests/webcodecs/sfx-aac.mp4 create mode 100644 testing/web-platform/tests/webcodecs/sfx-alaw.wav create mode 100644 testing/web-platform/tests/webcodecs/sfx-mulaw.wav create mode 100644 testing/web-platform/tests/webcodecs/sfx-opus.ogg create mode 100644 testing/web-platform/tests/webcodecs/sfx.adts create mode 100644 testing/web-platform/tests/webcodecs/sfx.mp3 create mode 100644 testing/web-platform/tests/webcodecs/temporal-svc-encoding.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/transfering.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/utils.js create mode 100644 testing/web-platform/tests/webcodecs/video-decoder.crossOriginIsolated.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/video-decoder.crossOriginIsolated.https.any.js.headers create mode 100644 testing/web-platform/tests/webcodecs/video-decoder.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/video-encoder-config.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/video-encoder-content-hint.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/video-encoder-flush.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/video-encoder-h264.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/video-encoder-utils.js create mode 100644 testing/web-platform/tests/webcodecs/video-encoder.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/video-frame-serialization.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoColorSpace.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoDecoder-codec-specific.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-alpha.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-canvasImageSource.html create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-construction.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-construction.crossOriginIsolated.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-construction.crossOriginIsolated.https.any.js.headers create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-construction.crossOriginSource.sub.html create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-construction.window.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-copyTo.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-copyTo.crossOriginIsolated.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-copyTo.crossOriginIsolated.https.any.js.headers create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-createImageBitmap.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-createImageBitmap.https.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-drawImage.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-odd-size.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-serialization.crossAgentCluster.helper.html create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-serialization.crossAgentCluster.https.html create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-serialization.https.html create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-texImage.any.js create mode 100644 testing/web-platform/tests/webcodecs/videoFrame-utils.js create mode 100644 testing/web-platform/tests/webcodecs/vp8.webm create mode 100644 testing/web-platform/tests/webcodecs/vp9.mp4 create mode 100644 testing/web-platform/tests/webcodecs/webgl-test-utils.js (limited to 'testing/web-platform/tests/webcodecs') diff --git a/testing/web-platform/tests/webcodecs/META.yml b/testing/web-platform/tests/webcodecs/META.yml new file mode 100644 index 0000000000..071ef88121 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/META.yml @@ -0,0 +1,7 @@ +spec: https://w3c.github.io/webcodecs/ +suggested_reviewers: + - Djuffin + - sandersdan + - youennf + - padenot + - ChunMinChang diff --git a/testing/web-platform/tests/webcodecs/README.md b/testing/web-platform/tests/webcodecs/README.md new file mode 100644 index 0000000000..0a1324a1ac --- /dev/null +++ b/testing/web-platform/tests/webcodecs/README.md @@ -0,0 +1,160 @@ +# WebCodecs Test Files + +[TOC] + +## Instructions + +To add, update or remove a test file, please update the list below. + +Please provide full reference and steps to generate the test file so that +any people can regenerate or update the file in the future. + +## Notes +* When updating the sample offsets and descriptions for tests using mp4 files, it's easiest to use [mp4box.js](https://gpac.github.io/mp4box.js/test/filereader.html). + * Sample offsets can be copied from the "Sample View" tab after unchecking all but offset and size. Use a multi-line edit mode and clang-format to quickly format entries. + * Description entries can be found under moov.trak.mdia.minf.stbl.stsd in box view. + * avc1.avcC or hvc1.hvcC has an offset, size in the same view. Add 8 to offset and subtract 8 from the size to get the values the tests want. + * If you use ffprobe -show_packets to get sample offsets, you may need to add 4 to each `pos` value. You can tell if you need to by whether or not tests pass. + +## List of Test Files + +### four-colors.png +Generated using MSPaint like a true professional. + +### four-colors.avif +Lossless encoding must be used to ensure colors are perfect. +``` +avifenc -l four-colors.png -o four-colors.avif +``` + +### four-colors.webp +Lossless encoding must be used to ensure colors are perfect. +``` +ffmpeg -i four-colors.png -lossless 1 -y four-colors.webp +``` + +### four-colors-limited-range-420-8bpc.webp +``` +ffmpeg -i four-colors.png -pix_fmt yuv420p four-colors-limited-range-420-8bpc.webp +``` + +### four-colors.gif +High quality encoding must be used to ensure colors are perfect. +``` +cp four-colors.png four-colors2.png +gifski -o four-colors.gif four-colors*.png +``` + +### four-colors-flip.gif +High quality encoding must be used to ensure colors are perfect. +``` +ffmpeg -i four-colors.png -vf "rotate=PI" four-colors2.png +gifski -o four-colors-flip.gif four-colors*.png +``` + +### four-colors-flip.avif +``` +ffmpeg -i four-colors-flip.gif -vcodec libaom-av1 -crf 16 four-colors-flip.mp4 +mp4box -add-image ref:primary:tk=1:samp=1 -ab avis -ab avif -ab miaf -brand avis four-colors-flip.mp4 -out four-colors-flip.avif +mp4box -edits 1=r four-colors-flip.avif +``` + +### four-colors-limited-range-(420|422|444)-8bpc.avif +``` +avifenc -r l -d 8 -y 420 -s 0 four-colors.png four-colors-limited-range-420-8bpc.avif +avifenc -r l -d 8 -y 422 -s 0 four-colors.png four-colors-limited-range-422-8bpc.avif +avifenc -r l -d 8 -y 444 -s 0 four-colors.png four-colors-limited-range-444-8bpc.avif +``` + +### four-colors-full-range-bt2020-pq-444-10bpc.avif +``` +avifenc -r f -d 10 -y 444 -s 0 --nclx 9/16/9 four-colors.png four-colors-full-range-bt2020-pq-444-10bpc.avif +``` + +### four-colors.jpg +Used [Sqoosh.app](https://squoosh.app/) with MozJPEG compression and RGB +channels. exiftool was then used to add an orientation marker. +``` +exiftool -Orientation=1 -n four-colors.jpg +``` + +### four-colors-limited-range-420-8bpc.jpg +Used [Sqoosh.app](https://squoosh.app/) with MozJPEG compression and YUV +channels. exiftool was then used to add an orientation marker. +``` +exiftool -Orientation=1 -n four-colors-limited-range-420-8bpc.jpg +``` + +### four-colors.mp4 +Used a [custom tool](https://storage.googleapis.com/dalecurtis/avif2mp4.html) to convert four-colors.avif into a .mp4 file. + +### h264.mp4 +``` +ffmpeg -f lavfi -i testsrc=rate=10:n=1 -t 1 -pix_fmt yuv420p -vcodec h264 -tune zerolatency h264.mp4 +``` + +### h264.annexb +``` +ffmpeg -i h264.mp4 -codec copy -bsf:v h264_mp4toannexb -f h264 h264.annexb +``` + +### h265.mp4 +``` +ffmpeg -f lavfi -i testsrc=rate=10:n=1 -t 1 -pix_fmt yuv420p -vcodec hevc -tag:v hvc1 -tune zerolatency h265.mp4 +``` + +### h265.annexb +``` +ffmpeg -i h265.mp4 -codec copy -bsf:v hevc_mp4toannexb -f hevc h265.annexb +``` + +### sfx.adts +``` +sox -n -r 48000 sfx.wav synth 1 sine 480 +ffmpeg -i sfx.wav -frames:a 10 -acodec aac -b:a 96K sfx.adts +``` + +### sfx-alaw.wav +``` +sox -n -r 48000 sfx.wav synth 1 sine 480 +ffmpeg -i sfx.wav -frames:a 10 -acodec pcm_alaw sfx-alaw.wav +``` + +### sfx.mp3 +``` +sox -n -r 48000 sfx.wav synth 1 sine 480 +ffmpeg -i sfx.wav -frames:a 10 -acodec libmp3lame -b:a 96K sfx.mp3 +``` + +### sfx-aac.mp4 +``` +sox -n -r 48000 sfx.wav synth 1 sine 480 +ffmpeg -i sfx.wav -frames:a 10 -acodec aac -b:a 96K sfx-aac.mp4 +``` + +### sfx-mulaw.wav +``` +sox -n -r 48000 sfx.wav synth 1 sine 480 +ffmpeg -i sfx.wav -frames:a 10 -acodec pcm_mulaw sfx-mulaw.wav +``` + +### sfx-opus.ogg +``` +sox -n -r 48000 sfx.wav synth 1 sine 480 +ffmpeg -i sfx.wav -frames:a 10 -acodec libopus -b:a 96K sfx-opus.ogg +``` + +### av1.mp4 +``` +ffmpeg -f lavfi -i testsrc=rate=10:n=1 -t 1 -pix_fmt yuv420p -vcodec libaom-av1 av1.mp4 +``` + +### vp8.webm +``` +ffmpeg -f lavfi -i testsrc=rate=10:n=1 -t 1 -pix_fmt yuv420p -vcodec vp8 vp8.webm +``` + +### vp9.mp4 +``` +ffmpeg -f lavfi -i testsrc=rate=10:n=1 -t 1 -pix_fmt yuv420p -vcodec vp9 vp9.mp4 +``` diff --git a/testing/web-platform/tests/webcodecs/WEB_FEATURES.yml b/testing/web-platform/tests/webcodecs/WEB_FEATURES.yml new file mode 100644 index 0000000000..89681db885 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/WEB_FEATURES.yml @@ -0,0 +1,3 @@ +features: +- name: webcodecs + files: "**" diff --git a/testing/web-platform/tests/webcodecs/audio-data-serialization.any.js b/testing/web-platform/tests/webcodecs/audio-data-serialization.any.js new file mode 100644 index 0000000000..280934cd05 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-data-serialization.any.js @@ -0,0 +1,93 @@ +// META: global=window +// META: script=/common/media.js +// META: script=/webcodecs/utils.js + +var defaultInit = { + timestamp: 1234, + channels: 2, + sampleRate: 8000, + frames: 100, +} + +function createDefaultAudioData() { + return make_audio_data(defaultInit.timestamp, + defaultInit.channels, + defaultInit.sampleRate, + defaultInit.frames); +} + +async_test(t => { + let originalData = createDefaultAudioData(); + + let channel = new MessageChannel(); + let localPort = channel.port1; + let externalPort = channel.port2; + + externalPort.onmessage = t.step_func((e) => { + let newData = e.data; + + // We should have a valid deserialized buffer. + assert_equals(newData.numberOfFrames, defaultInit.frames, 'numberOfFrames'); + assert_equals( + newData.numberOfChannels, defaultInit.channels, 'numberOfChannels'); + assert_equals(newData.sampleRate, defaultInit.sampleRate, 'sampleRate'); + + const originalData_copyDest = new Float32Array(defaultInit.frames); + const newData_copyDest = new Float32Array(defaultInit.frames); + + for (var channel = 0; channel < defaultInit.channels; channel++) { + originalData.copyTo(originalData_copyDest, { planeIndex: channel}); + newData.copyTo(newData_copyDest, { planeIndex: channel}); + + for (var i = 0; i < newData_copyDest.length; i+=10) { + assert_equals(newData_copyDest[i], originalData_copyDest[i], + "data (ch=" + channel + ", i=" + i + ")"); + } + } + + newData.close(); + externalPort.postMessage("Done"); + }) + + localPort.onmessage = t.step_func_done((e) => { + assert_equals(originalData.numberOfFrames, defaultInit.frames); + originalData.close(); + }) + + localPort.postMessage(originalData); + +}, 'Verify closing AudioData does not propagate accross contexts.'); + +async_test(t => { + let data = createDefaultAudioData(); + + let channel = new MessageChannel(); + let localPort = channel.port1; + + localPort.onmessage = t.unreached_func(); + + data.close(); + + assert_throws_dom("DataCloneError", () => { + localPort.postMessage(data); + }); + + t.done(); +}, 'Verify posting closed AudioData throws.'); + +async_test(t => { + let localData = createDefaultAudioData(); + + let channel = new MessageChannel(); + let localPort = channel.port1; + let externalPort = channel.port2; + + externalPort.onmessage = t.step_func_done((e) => { + let externalData = e.data; + assert_equals(externalData.numberOfFrames, defaultInit.frames); + externalData.close(); + }) + + localPort.postMessage(localData, [localData]); + assert_not_equals(localData.numberOfFrames, defaultInit.frames); +}, 'Verify transferring audio data closes them.'); \ No newline at end of file diff --git a/testing/web-platform/tests/webcodecs/audio-data.any.js b/testing/web-platform/tests/webcodecs/audio-data.any.js new file mode 100644 index 0000000000..4c2d96ab80 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-data.any.js @@ -0,0 +1,357 @@ +// META: global=window,dedicatedworker +// META: script=/common/media.js +// META: script=/webcodecs/utils.js + +var defaultInit = + { + timestamp: 1234, + channels: 2, + sampleRate: 8000, + frames: 100, + } + +function +createDefaultAudioData() { + return make_audio_data( + defaultInit.timestamp, defaultInit.channels, defaultInit.sampleRate, + defaultInit.frames); +} + +test(t => { + let local_data = new Float32Array(defaultInit.channels * defaultInit.frames); + + let audio_data_init = { + timestamp: defaultInit.timestamp, + data: local_data, + numberOfFrames: defaultInit.frames, + numberOfChannels: defaultInit.channels, + sampleRate: defaultInit.sampleRate, + format: 'f32-planar', + } + + let data = new AudioData(audio_data_init); + + assert_equals(data.timestamp, defaultInit.timestamp, 'timestamp'); + assert_equals(data.numberOfFrames, defaultInit.frames, 'frames'); + assert_equals(data.numberOfChannels, defaultInit.channels, 'channels'); + assert_equals(data.sampleRate, defaultInit.sampleRate, 'sampleRate'); + assert_equals( + data.duration, defaultInit.frames / defaultInit.sampleRate * 1_000_000, + 'duration'); + assert_equals(data.format, 'f32-planar', 'format'); + + // Create an Int16 array of the right length. + let small_data = new Int16Array(defaultInit.channels * defaultInit.frames); + + let wrong_format_init = {...audio_data_init}; + wrong_format_init.data = small_data; + + // Creating `f32-planar` AudioData from Int16 from should throw. + assert_throws_js(TypeError, () => { + let data = new AudioData(wrong_format_init); + }, `AudioDataInit.data needs to be big enough`); + + var members = [ + 'timestamp', + 'data', + 'numberOfFrames', + 'numberOfChannels', + 'sampleRate', + 'format', + ]; + + for (const member of members) { + let incomplete_init = {...audio_data_init}; + delete incomplete_init[member]; + + assert_throws_js( + TypeError, () => {let data = new AudioData(incomplete_init)}, + 'AudioData requires \'' + member + '\''); + } + + let invalid_init = {...audio_data_init}; + invalid_init.numberOfFrames = 0 + + assert_throws_js( + TypeError, () => {let data = new AudioData(invalid_init)}, + 'AudioData requires numberOfFrames > 0'); + + invalid_init = {...audio_data_init}; + invalid_init.numberOfChannels = 0 + + assert_throws_js( + TypeError, () => {let data = new AudioData(invalid_init)}, + 'AudioData requires numberOfChannels > 0'); + +}, 'Verify AudioData constructors'); + +test(t => { + let data = createDefaultAudioData(); + + let clone = data.clone(); + + // Verify the parameters match. + assert_equals(data.timestamp, clone.timestamp, 'timestamp'); + assert_equals(data.numberOfFrames, clone.numberOfFrames, 'frames'); + assert_equals(data.numberOfChannels, clone.numberOfChannels, 'channels'); + assert_equals(data.sampleRate, clone.sampleRate, 'sampleRate'); + assert_equals(data.format, clone.format, 'format'); + + const data_copyDest = new Float32Array(defaultInit.frames); + const clone_copyDest = new Float32Array(defaultInit.frames); + + // Verify the data matches. + for (var channel = 0; channel < defaultInit.channels; channel++) { + data.copyTo(data_copyDest, {planeIndex: channel}); + clone.copyTo(clone_copyDest, {planeIndex: channel}); + + assert_array_equals( + data_copyDest, clone_copyDest, 'Cloned data ch=' + channel); + } + + // Verify closing the original data doesn't close the clone. + data.close(); + assert_equals(data.numberOfFrames, 0, 'data.buffer (closed)'); + assert_not_equals(clone.numberOfFrames, 0, 'clone.buffer (not closed)'); + + clone.close(); + assert_equals(clone.numberOfFrames, 0, 'clone.buffer (closed)'); + + // Verify closing a closed AudioData does not throw. + data.close(); +}, 'Verify closing and cloning AudioData'); + +test(t => { + let data = make_audio_data( + -10, defaultInit.channels, defaultInit.sampleRate, defaultInit.frames); + assert_equals(data.timestamp, -10, 'timestamp'); + data.close(); +}, 'Test we can construct AudioData with a negative timestamp.'); + + +// Each test vector represents two channels of data in the following arbitrary +// layout: . +const testVectorFrames = 5; +const testVectorChannels = 2; +const testVectorInterleavedResult = + [[-1.0, 1.0, 0.5, 0.0, 0.0], [0.0, -1.0, -0.5, 1.0, 0.0]]; +const testVectorPlanarResult = + [[-1.0, 0.0, 1.0, -1.0, 0.5], [-0.5, 0.0, 1.0, 0.0, 0.0]]; + +test(t => { + const INT8_MIN = (-0x7f - 1); + const INT8_MAX = 0x7f; + const UINT8_MAX = 0xff; + + const testVectorUint8 = [ + 0, -INT8_MIN, UINT8_MAX, 0, INT8_MAX / 2 + 128, INT8_MIN / 2 + 128, + -INT8_MIN, UINT8_MAX, -INT8_MIN, -INT8_MIN + ]; + + let data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Uint8Array(testVectorUint8), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 'u8' + }); + + const epsilon = 1.0 / (UINT8_MAX - 1); + + let dest = new Float32Array(data.numberOfFrames); + data.copyTo(dest, {planeIndex: 0, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorInterleavedResult[0], epsilon, 'interleaved channel 0'); + data.copyTo(dest, {planeIndex: 1, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorInterleavedResult[1], epsilon, 'interleaved channel 1'); + + data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Uint8Array(testVectorUint8), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 'u8-planar' + }); + + data.copyTo(dest, {planeIndex: 0, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorPlanarResult[0], epsilon, 'planar channel 0'); + data.copyTo(dest, {planeIndex: 1, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorPlanarResult[1], epsilon, 'planar channel 1'); +}, 'Test conversion of uint8 data to float32'); + +test(t => { + const INT16_MIN = (-0x7fff - 1); + const INT16_MAX = 0x7fff; + const testVectorInt16 = [ + INT16_MIN, 0, INT16_MAX, INT16_MIN, INT16_MAX / 2, INT16_MIN / 2, 0, + INT16_MAX, 0, 0 + ]; + + let data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Int16Array(testVectorInt16), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 's16' + }); + + const epsilon = 1.0 / (INT16_MAX + 1); + + let dest = new Float32Array(data.numberOfFrames); + data.copyTo(dest, {planeIndex: 0, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorInterleavedResult[0], epsilon, 'interleaved channel 0'); + data.copyTo(dest, {planeIndex: 1, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorInterleavedResult[1], epsilon, 'interleaved channel 1'); + + data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Int16Array(testVectorInt16), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 's16-planar' + }); + + data.copyTo(dest, {planeIndex: 0, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorPlanarResult[0], epsilon, 'planar channel 0'); + data.copyTo(dest, {planeIndex: 1, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorPlanarResult[1], epsilon, 'planar channel 1'); +}, 'Test conversion of int16 data to float32'); + +test(t => { + const INT32_MIN = (-0x7fffffff - 1); + const INT32_MAX = 0x7fffffff; + const testVectorInt32 = [ + INT32_MIN, 0, INT32_MAX, INT32_MIN, INT32_MAX / 2, INT32_MIN / 2, 0, + INT32_MAX, 0, 0 + ]; + + let data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Int32Array(testVectorInt32), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 's32' + }); + + const epsilon = 1.0 / INT32_MAX; + + let dest = new Float32Array(data.numberOfFrames); + data.copyTo(dest, {planeIndex: 0, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorInterleavedResult[0], epsilon, 'interleaved channel 0'); + data.copyTo(dest, {planeIndex: 1, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorInterleavedResult[1], epsilon, 'interleaved channel 1'); + + data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Int32Array(testVectorInt32), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 's32-planar' + }); + + data.copyTo(dest, {planeIndex: 0, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorPlanarResult[0], epsilon, 'planar channel 0'); + data.copyTo(dest, {planeIndex: 1, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorPlanarResult[1], epsilon, 'planar channel 1'); +}, 'Test conversion of int32 data to float32'); + +test(t => { + const testVectorFloat32 = + [-1.0, 0.0, 1.0, -1.0, 0.5, -0.5, 0.0, 1.0, 0.0, 0.0]; + + let data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Float32Array(testVectorFloat32), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 'f32' + }); + + const epsilon = 0; + + let dest = new Float32Array(data.numberOfFrames); + data.copyTo(dest, {planeIndex: 0, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorInterleavedResult[0], epsilon, 'interleaved channel 0'); + data.copyTo(dest, {planeIndex: 1, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorInterleavedResult[1], epsilon, 'interleaved channel 1'); + + data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Float32Array(testVectorFloat32), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 'f32-planar' + }); + + data.copyTo(dest, {planeIndex: 0, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorPlanarResult[0], epsilon, 'planar channel 0'); + data.copyTo(dest, {planeIndex: 1, format: 'f32-planar'}); + assert_array_approx_equals( + dest, testVectorPlanarResult[1], epsilon, 'planar channel 1'); +}, 'Test conversion of float32 data to float32'); + +test(t => { + const testVectorFloat32 = + [-1.0, 0.0, 1.0, -1.0, 0.5, -0.5, 0.0, 1.0, 0.0, 0.0]; + + let data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Float32Array(testVectorFloat32), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 'f32' + }); + + const epsilon = 0; + + // Call copyTo() without specifying a format, for interleaved data. + let dest = new Float32Array(data.numberOfFrames * testVectorChannels); + data.copyTo(dest, {planeIndex: 0}); + assert_array_approx_equals( + dest, testVectorFloat32, epsilon, 'interleaved data'); + + assert_throws_js(RangeError, () => { + data.copyTo(dest, {planeIndex: 1}); + }, 'Interleaved AudioData cannot copy out planeIndex > 0'); + + data = new AudioData({ + timestamp: defaultInit.timestamp, + data: new Float32Array(testVectorFloat32), + numberOfFrames: testVectorFrames, + numberOfChannels: testVectorChannels, + sampleRate: defaultInit.sampleRate, + format: 'f32-planar' + }); + + // Call copyTo() without specifying a format, for planar data. + dest = new Float32Array(data.numberOfFrames); + data.copyTo(dest, {planeIndex: 0}); + assert_array_approx_equals( + dest, testVectorPlanarResult[0], epsilon, 'planar channel 0'); + data.copyTo(dest, {planeIndex: 1}); + assert_array_approx_equals( + dest, testVectorPlanarResult[1], epsilon, 'planar channel 1'); +}, 'Test copying out planar and interleaved data'); diff --git a/testing/web-platform/tests/webcodecs/audio-data.crossOriginIsolated.https.any.js b/testing/web-platform/tests/webcodecs/audio-data.crossOriginIsolated.https.any.js new file mode 100644 index 0000000000..a5cb478670 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-data.crossOriginIsolated.https.any.js @@ -0,0 +1,44 @@ +// META: global=window +// META: script=/common/media.js +// META: script=/webcodecs/utils.js + +var defaultInit = { + timestamp: 1234, + channels: 2, + sampleRate: 8000, + frames: 1, +}; + +function testAudioData(useView) { + let localData = + new SharedArrayBuffer(defaultInit.channels * defaultInit.frames * 4); + let view = new Float32Array(localData); + view[0] = -1.0; + view[1] = 1.0; + + let audio_data_init = { + timestamp: defaultInit.timestamp, + data: useView ? view : localData, + numberOfFrames: defaultInit.frames, + numberOfChannels: defaultInit.channels, + sampleRate: defaultInit.sampleRate, + format: 'f32-planar', + } + + let data = new AudioData(audio_data_init); + + let copyDest = new SharedArrayBuffer(data.allocationSize({planeIndex: 0})); + let destView = new Float32Array(copyDest); + data.copyTo(useView ? destView : copyDest, {planeIndex: 0}); + assert_equals(destView[0], -1.0, 'copyDest[0]'); + data.copyTo(useView ? destView : copyDest, {planeIndex: 1}); + assert_equals(destView[0], 1.0, 'copyDest[1]'); +} + +test(t => { + testAudioData(/*useView=*/ false); +}, 'Test construction and copyTo() using a SharedArrayBuffer'); + +test(t => { + testAudioData(/*useView=*/ true); +}, 'Test construction and copyTo() using a Uint8Array(SharedArrayBuffer)'); diff --git a/testing/web-platform/tests/webcodecs/audio-data.crossOriginIsolated.https.any.js.headers b/testing/web-platform/tests/webcodecs/audio-data.crossOriginIsolated.https.any.js.headers new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-data.crossOriginIsolated.https.any.js.headers @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/testing/web-platform/tests/webcodecs/audio-decoder.crossOriginIsolated.https.any.js b/testing/web-platform/tests/webcodecs/audio-decoder.crossOriginIsolated.https.any.js new file mode 100644 index 0000000000..17009e0118 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-decoder.crossOriginIsolated.https.any.js @@ -0,0 +1,71 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +const testData = { + src: 'sfx-aac.mp4', + config: { + codec: 'mp4a.40.2', + sampleRate: 48000, + numberOfChannels: 1, + description: {offset: 2552, size: 5}, + } +}; + +// Create a view of an ArrayBuffer. +function view(buffer, {offset, size}) { + return new Uint8Array(buffer, offset, size); +} + +function testSharedArrayBufferDescription(t, useView) { + const data = testData; + + // Don't run test if the codec is not supported. + assert_equals("function", typeof AudioDecoder.isConfigSupported); + let supported = false; + return AudioDecoder + .isConfigSupported({ + codec: data.config.codec, + sampleRate: data.config.sampleRate, + numberOfChannels: data.config.numberOfChannels + }) + .catch(_ => { + assert_implements_optional(false, data.config.codec + ' unsupported'); + }) + .then(support => { + supported = support.supported; + assert_implements_optional( + supported, data.config.codec + ' unsupported'); + return fetch(data.src); + }) + .then(response => { + return response.arrayBuffer(); + }) + .then(buf => { + config = {...data.config}; + if (data.config.description) { + let desc = new SharedArrayBuffer(data.config.description.size); + let descView = new Uint8Array(desc); + descView.set(view(buf, data.config.description)); + config.description = useView ? descView : desc; + } + + // Support was verified above, so the description shouldn't change + // that. + return AudioDecoder.isConfigSupported(config); + }) + .then(support => { + assert_true(support.supported); + + const decoder = new AudioDecoder(getDefaultCodecInit(t)); + decoder.configure(config); + assert_equals(decoder.state, 'configured', 'state'); + }); +} + +promise_test(t => { + return testSharedArrayBufferDescription(t, /*useView=*/ false); +}, 'Test isConfigSupported() and configure() using a SharedArrayBuffer'); + +promise_test(t => { + return testSharedArrayBufferDescription(t, /*useView=*/ true); +}, 'Test isConfigSupported() and configure() using a Uint8Array(SharedArrayBuffer)'); diff --git a/testing/web-platform/tests/webcodecs/audio-decoder.crossOriginIsolated.https.any.js.headers b/testing/web-platform/tests/webcodecs/audio-decoder.crossOriginIsolated.https.any.js.headers new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-decoder.crossOriginIsolated.https.any.js.headers @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/testing/web-platform/tests/webcodecs/audio-decoder.https.any.js b/testing/web-platform/tests/webcodecs/audio-decoder.https.any.js new file mode 100644 index 0000000000..79ba22157a --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-decoder.https.any.js @@ -0,0 +1,187 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +const invalidConfigs = [ + { + comment: 'Missing codec', + config: { + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Empty codec', + config: { + codec: '', + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Missing sampleRate', + config: { + codec: 'opus', + sampleRate: 48000, + }, + }, + { + comment: 'Missing numberOfChannels', + config: { + codec: 'opus', + sampleRate: 48000, + }, + }, + { + comment: 'Zero sampleRate', + config: { + codec: 'opus', + sampleRate: 0, + numberOfChannels: 2, + }, + }, + { + comment: 'Zero channels', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 0, + }, + }, +]; + +invalidConfigs.forEach(entry => { + promise_test( + t => { + return promise_rejects_js( + t, TypeError, AudioDecoder.isConfigSupported(entry.config)); + }, + 'Test that AudioDecoder.isConfigSupported() rejects invalid config: ' + + entry.comment); +}); + +invalidConfigs.forEach(entry => { + async_test( + t => { + let codec = new AudioDecoder(getDefaultCodecInit(t)); + assert_throws_js(TypeError, () => { + codec.configure(entry.config); + }); + t.done(); + }, + 'Test that AudioDecoder.configure() rejects invalid config: ' + + entry.comment); +}); + +const validButUnsupportedConfigs = [ + { + comment: 'Unrecognized codec', + config: { + codec: 'bogus', + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Video codec', + config: { + codec: 'vp8', + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Ambiguous codec', + config: { + codec: 'vp9', + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Codec with MIME type', + config: { + codec: 'audio/webm; codecs="opus"', + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Possible future opus codec string', + config: { + codec: 'opus.123', + sampleRate: 48000, + numberOfChannels: 2, + } + }, + { + comment: 'Possible future aac codec string', + config: { + codec: 'mp4a.FF.9', + sampleRate: 48000, + numberOfChannels: 2, + } + }, +]; + +validButUnsupportedConfigs.forEach(entry => { + promise_test( + t => { + return AudioDecoder.isConfigSupported(entry.config).then(support => { + assert_false(support.supported); + }); + }, + 'Test that AudioDecoder.isConfigSupported() doesn\'t support config: ' + + entry.comment); +}); + +validButUnsupportedConfigs.forEach(entry => { + promise_test( + t => { + let isErrorCallbackCalled = false; + let codec = new AudioDecoder({ + output: t.unreached_func('unexpected output'), + error: t.step_func_done(e => { + isErrorCallbackCalled = true; + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(codec.state, 'closed', 'state'); + }) + }); + codec.configure(entry.config); + return codec.flush() + .then(t.unreached_func('flush succeeded unexpectedly')) + .catch(t.step_func(e => { + assert_true(isErrorCallbackCalled, "isErrorCallbackCalled"); + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(codec.state, 'closed', 'state'); + })); + }, + 'Test that AudioDecoder.configure() doesn\'t support config: ' + + entry.comment); +}); + +function getFakeChunk() { + return new EncodedAudioChunk( + {type: 'key', timestamp: 0, data: Uint8Array.of(0)}); +} + +promise_test(t => { + // AudioDecoderInit lacks required fields. + assert_throws_js(TypeError, () => { + new AudioDecoder({}); + }); + + // AudioDecoderInit has required fields. + let decoder = new AudioDecoder(getDefaultCodecInit(t)); + + assert_equals(decoder.state, 'unconfigured'); + decoder.close(); + + return endAfterEventLoopTurn(); +}, 'Test AudioDecoder construction'); + +promise_test(t => { + let decoder = new AudioDecoder(getDefaultCodecInit(t)); + return testUnconfiguredCodec(t, decoder, getFakeChunk()); +}, 'Verify unconfigured AudioDecoder operations'); diff --git a/testing/web-platform/tests/webcodecs/audio-encoder-codec-specific.https.any.js b/testing/web-platform/tests/webcodecs/audio-encoder-codec-specific.https.any.js new file mode 100644 index 0000000000..e3396f999a --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-encoder-codec-specific.https.any.js @@ -0,0 +1,280 @@ +// META: global=window +// META: script=/webcodecs/utils.js + +function make_silent_audio_data(timestamp, channels, sampleRate, frames) { + let data = new Float32Array(frames*channels); + + return new AudioData({ + timestamp: timestamp, + data: data, + numberOfChannels: channels, + numberOfFrames: frames, + sampleRate: sampleRate, + format: "f32-planar", + }); +} + +// The Opus DTX flag (discontinuous transmission) reduces the encoding bitrate +// for silence. This test ensures the DTX flag is working properly by encoding +// almost 10s of silence and comparing the bitrate with and without the flag. +promise_test(async t => { + let sample_rate = 48000; + let total_duration_s = 10; + let data_count = 100; + let normal_outputs = []; + let dtx_outputs = []; + + let normal_encoder = new AudioEncoder({ + error: e => { + assert_unreached('error: ' + e); + }, + output: chunk => { + normal_outputs.push(chunk); + } + }); + + let dtx_encoder = new AudioEncoder({ + error: e => { + assert_unreached('error: ' + e); + }, + output: chunk => { + dtx_outputs.push(chunk); + } + }); + + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 256000, // 256kbit + }; + + let normal_config = {...config, opus: {usedtx: false}}; + let dtx_config = {...config, opus: {usedtx: true}}; + + let normal_config_support = await AudioEncoder.isConfigSupported(normal_config); + assert_implements_optional(normal_config_support.supported, "Opus not supported"); + + let dtx_config_support = await AudioEncoder.isConfigSupported(dtx_config); + assert_implements_optional(dtx_config_support.supported, "Opus DTX not supported"); + + // Configure one encoder with and one without the DTX flag + normal_encoder.configure(normal_config); + dtx_encoder.configure(dtx_config); + + let timestamp_us = 0; + let data_duration_s = total_duration_s / data_count; + let data_length = data_duration_s * config.sampleRate; + for (let i = 0; i < data_count; i++) { + let data; + + if (i == 0 || i == (data_count - 1)) { + // Send real data for the first and last 100ms. + data = make_audio_data( + timestamp_us, config.numberOfChannels, config.sampleRate, + data_length); + + } else { + // Send silence for the rest of the 10s. + data = make_silent_audio_data( + timestamp_us, config.numberOfChannels, config.sampleRate, + data_length); + } + + normal_encoder.encode(data); + dtx_encoder.encode(data); + data.close(); + + timestamp_us += data_duration_s * 1_000_000; + } + + await Promise.all([normal_encoder.flush(), dtx_encoder.flush()]) + + normal_encoder.close(); + dtx_encoder.close(); + + // We expect a significant reduction in the number of packets, over ~10s of silence. + assert_less_than(dtx_outputs.length, (normal_outputs.length / 2)); +}, 'Test the Opus DTX flag works.'); + + +// The Opus bitrateMode enum chooses whether we use a constant or variable bitrate. +// This test ensures that VBR/CBR is respected properly by encoding almost 10s of +// silence and comparing the size of the encoded variable or constant bitrates. +promise_test(async t => { + let sample_rate = 48000; + let total_duration_s = 10; + let data_count = 100; + let vbr_outputs = []; + let cbr_outputs = []; + + let cbr_encoder = new AudioEncoder({ + error: e => { + assert_unreached('error: ' + e); + }, + output: chunk => { + cbr_outputs.push(chunk); + } + }); + + let vbr_encoder = new AudioEncoder({ + error: e => { + assert_unreached('error: ' + e); + }, + output: chunk => { + vbr_outputs.push(chunk); + } + }); + + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 256000, // 256kbit + }; + + let cbr_config = { ...config, bitrateMode: "constant" }; + let vbr_config = { ...config, bitrateMode: "variable" }; + + let cbr_config_support = await AudioEncoder.isConfigSupported(cbr_config); + assert_implements_optional(cbr_config_support.supported, "Opus CBR not supported"); + + let vbr_config_support = await AudioEncoder.isConfigSupported(vbr_config); + assert_implements_optional(vbr_config_support.supported, "Opus VBR not supported"); + + // Configure one encoder with VBR and one CBR. + cbr_encoder.configure(cbr_config); + vbr_encoder.configure(vbr_config); + + let timestamp_us = 0; + let data_duration_s = total_duration_s / data_count; + let data_length = data_duration_s * config.sampleRate; + for (let i = 0; i < data_count; i++) { + let data; + + if (i == 0 || i == (data_count - 1)) { + // Send real data for the first and last 100ms. + data = make_audio_data( + timestamp_us, config.numberOfChannels, config.sampleRate, + data_length); + + } else { + // Send silence for the rest of the 10s. + data = make_silent_audio_data( + timestamp_us, config.numberOfChannels, config.sampleRate, + data_length); + } + + vbr_encoder.encode(data); + cbr_encoder.encode(data); + data.close(); + + timestamp_us += data_duration_s * 1_000_000; + } + + await Promise.all([cbr_encoder.flush(), vbr_encoder.flush()]) + + cbr_encoder.close(); + vbr_encoder.close(); + + let vbr_total_bytes = 0; + vbr_outputs.forEach(chunk => vbr_total_bytes += chunk.byteLength) + + let cbr_total_bytes = 0; + cbr_outputs.forEach(chunk => cbr_total_bytes += chunk.byteLength) + + // We expect a significant reduction in the size of the packets, over ~10s of silence. + assert_less_than(vbr_total_bytes, (cbr_total_bytes / 2)); +}, 'Test the Opus bitrateMode flag works.'); + + +// The AAC bitrateMode enum chooses whether we use a constant or variable bitrate. +// This test exercises the VBR/CBR paths. Some platforms don't support VBR for AAC, +// and still emit a constant bitrate. +promise_test(async t => { + let sample_rate = 48000; + let total_duration_s = 10; + let data_count = 100; + let vbr_outputs = []; + let cbr_outputs = []; + + let cbr_encoder = new AudioEncoder({ + error: e => { + assert_unreached('error: ' + e); + }, + output: chunk => { + cbr_outputs.push(chunk); + } + }); + + let vbr_encoder = new AudioEncoder({ + error: e => { + assert_unreached('error: ' + e); + }, + output: chunk => { + vbr_outputs.push(chunk); + } + }); + + let config = { + codec: 'mp4a.40.2', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 192000, // 256kbit + }; + + let cbr_config = { ...config, bitrateMode: "constant" }; + let vbr_config = { ...config, bitrateMode: "variable" }; + + let cbr_config_support = await AudioEncoder.isConfigSupported(cbr_config); + assert_implements_optional(cbr_config_support.supported, "AAC CBR not supported"); + + let vbr_config_support = await AudioEncoder.isConfigSupported(vbr_config); + assert_implements_optional(vbr_config_support.supported, "AAC VBR not supported"); + + // Configure one encoder with VBR and one CBR. + cbr_encoder.configure(cbr_config); + vbr_encoder.configure(vbr_config); + + let timestamp_us = 0; + let data_duration_s = total_duration_s / data_count; + let data_length = data_duration_s * config.sampleRate; + for (let i = 0; i < data_count; i++) { + let data; + + if (i == 0 || i == (data_count - 1)) { + // Send real data for the first and last 100ms. + data = make_audio_data( + timestamp_us, config.numberOfChannels, config.sampleRate, + data_length); + + } else { + // Send silence for the rest of the 10s. + data = make_silent_audio_data( + timestamp_us, config.numberOfChannels, config.sampleRate, + data_length); + } + + vbr_encoder.encode(data); + cbr_encoder.encode(data); + data.close(); + + timestamp_us += data_duration_s * 1_000_000; + } + + await Promise.all([cbr_encoder.flush(), vbr_encoder.flush()]) + + cbr_encoder.close(); + vbr_encoder.close(); + + let vbr_total_bytes = 0; + vbr_outputs.forEach(chunk => vbr_total_bytes += chunk.byteLength) + + let cbr_total_bytes = 0; + cbr_outputs.forEach(chunk => cbr_total_bytes += chunk.byteLength) + + // We'd like to confirm that the encoded size using VBR is less than CBR, but + // platforms without VBR support will silently revert to CBR (which is + // technically a subset of VBR). + assert_less_than_equal(vbr_total_bytes, cbr_total_bytes); +}, 'Test the AAC bitrateMode flag works.'); diff --git a/testing/web-platform/tests/webcodecs/audio-encoder-config.https.any.js b/testing/web-platform/tests/webcodecs/audio-encoder-config.https.any.js new file mode 100644 index 0000000000..3be8eb3f6d --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-encoder-config.https.any.js @@ -0,0 +1,341 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +const invalidConfigs = [ + { + comment: 'Missing codec', + config: { + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Empty codec', + config: { + codec: '', + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Missing sampleRate', + config: { + codec: 'opus', + sampleRate: 48000, + }, + }, + { + comment: 'Missing numberOfChannels', + config: { + codec: 'opus', + sampleRate: 48000, + }, + }, + { + comment: 'Zero sampleRate', + config: { + codec: 'opus', + sampleRate: 0, + numberOfChannels: 2, + }, + }, + { + comment: 'Zero channels', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 0, + }, + }, + { + comment: 'Bit rate too big', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 2, + bitrate: 6e9, + }, + }, + { + comment: 'Opus complexity too big', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 2, + opus: { + complexity: 11, + }, + }, + }, + { + comment: 'Opus packetlossperc too big', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 2, + opus: { + packetlossperc: 101, + }, + }, + }, + { + comment: 'Opus frame duration too small', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 2, + opus: { + frameDuration: 0, + }, + }, + }, + { + comment: 'Opus frame duration too big', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 2, + opus: { + frameDuration: 120 * 1000 + 1, + }, + }, + }, + { + comment: 'Invalid Opus frameDuration', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 2, + opus: { + frameDuration: 2501, + }, + }, + }, +]; + +invalidConfigs.forEach(entry => { + promise_test( + t => { + return promise_rejects_js( + t, TypeError, AudioEncoder.isConfigSupported(entry.config)); + }, + 'Test that AudioEncoder.isConfigSupported() rejects invalid config: ' + + entry.comment); +}); + +invalidConfigs.forEach(entry => { + async_test( + t => { + let codec = new AudioEncoder(getDefaultCodecInit(t)); + assert_throws_js(TypeError, () => { + codec.configure(entry.config); + }); + t.done(); + }, + 'Test that AudioEncoder.configure() rejects invalid config: ' + + entry.comment); +}); + +const validButUnsupportedConfigs = [ + { + comment: 'Bitrate is too low', + config: { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 2, + bitrate: 1, + }, + }, + { + comment: 'Unrecognized codec', + config: { + codec: 'bogus', + sampleRate: 48000, + numberOfChannels: 2, + }, + }, + { + comment: 'Sample rate is too small', + config: { + codec: 'opus', + sampleRate: 1, + numberOfChannels: 2, + }, + }, + { + comment: 'Sample rate is too large', + config: { + codec: 'opus', + sampleRate: 10000000, + numberOfChannels: 2, + }, + }, + { + comment: 'Way too many channels', + config: { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 1024, + bitrate: 128000, + }, + }, + { + comment: 'Possible future opus codec string', + config: { + codec: 'opus.123', + sampleRate: 48000, + numberOfChannels: 2, + } + }, + { + comment: 'Possible future aac codec string', + config: { + codec: 'mp4a.FF.9', + sampleRate: 48000, + numberOfChannels: 2, + } + }, +]; + +validButUnsupportedConfigs.forEach(entry => { + promise_test( + async t => { + let support = await AudioEncoder.isConfigSupported(entry.config); + assert_false(support.supported); + + let config = support.config; + assert_equals(config.codec, entry.config.codec); + assert_equals(config.sampleRate, entry.config.sampleRate); + assert_equals(config.numberOfChannels, entry.config.numberOfChannels); + }, + 'Test that AudioEncoder.isConfigSupported() doesn\'t support config: ' + + entry.comment); +}); + +validButUnsupportedConfigs.forEach(entry => { + promise_test( + t => { + let isErrorCallbackCalled = false; + let codec = new AudioEncoder({ + output: t.unreached_func('unexpected output'), + error: t.step_func_done(e => { + isErrorCallbackCalled = true; + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(codec.state, 'closed', 'state'); + }) + }); + codec.configure(entry.config); + return codec.flush() + .then(t.unreached_func('flush succeeded unexpectedly')) + .catch(t.step_func(e => { + assert_true(isErrorCallbackCalled, "isErrorCallbackCalled"); + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(codec.state, 'closed', 'state'); + })); + }, + 'Test that AudioEncoder.configure() doesn\'t support config: ' + + entry.comment); +}); + +const validConfigs = [ + { + codec: 'opus', + sampleRate: 8000, + numberOfChannels: 1, + }, + { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 2, + }, + { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 2, + bitrate: 128000, + bitrateMode: "constant", + bogus: 123 + }, + { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 2, + bitrate: 128000, + bitrateMode: "variable", + bogus: 123 + }, + { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 2, + opus: { + complexity: 5, + frameDuration: 20000, + packetlossperc: 10, + useinbandfec: true, + }, + }, + { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 2, + opus: { + format: 'opus', + complexity: 10, + frameDuration: 60000, + packetlossperc: 20, // Irrelevant without useinbandfec, but still valid. + usedtx: true, + bogus: 456, + }, + }, + { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 2, + opus: {}, // Use default values. + }, +]; + +validConfigs.forEach(config => { + promise_test(async t => { + let support = await AudioEncoder.isConfigSupported(config); + assert_true(support.supported); + + let new_config = support.config; + assert_equals(new_config.codec, config.codec); + assert_equals(new_config.sampleRate, config.sampleRate); + assert_equals(new_config.numberOfChannels, config.numberOfChannels); + if (config.bitrate) + assert_equals(new_config.bitrate, config.bitrate); + + if (config.opus) { + let opus_config = config.opus; + let new_opus_config = new_config.opus; + + assert_equals(new_opus_config.format, opus_config.format ?? 'opus'); + assert_equals( + new_opus_config.frameDuration, opus_config.frameDuration ?? 20000); + assert_equals( + new_opus_config.packetlossperc, opus_config.packetlossperc ?? 0); + assert_equals( + new_opus_config.useinbandfec, opus_config.useinbandfec ?? false); + assert_equals(new_opus_config.usedtx, opus_config.usedtx ?? false); + assert_false(new_opus_config.hasOwnProperty('bogus')); + + if (opus_config.complexity) { + assert_equals(new_opus_config.complexity, opus_config.complexity); + } else { + // Default complexity is 5 for mobile/ARM platforms, and 9 otherwise. + assert_true( + new_opus_config.complexity == 5 || new_opus_config.complexity == 9); + } + + } else { + assert_false(new_config.hasOwnProperty('opus')); + } + + assert_false(new_config.hasOwnProperty('bogus')); + }, 'AudioEncoder.isConfigSupported() supports: ' + JSON.stringify(config)); +}); diff --git a/testing/web-platform/tests/webcodecs/audio-encoder.https.any.js b/testing/web-platform/tests/webcodecs/audio-encoder.https.any.js new file mode 100644 index 0000000000..51496551cf --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audio-encoder.https.any.js @@ -0,0 +1,627 @@ +// META: global=window +// META: script=/webcodecs/utils.js + +// Merge all audio buffers into a new big one with all the data. +function join_audio_data(audio_data_array) { + assert_greater_than_equal(audio_data_array.length, 0); + let total_frames = 0; + let base_buffer = audio_data_array[0]; + for (const data of audio_data_array) { + assert_not_equals(data, null); + assert_equals(data.sampleRate, base_buffer.sampleRate); + assert_equals(data.numberOfChannels, base_buffer.numberOfChannels); + assert_equals(data.format, base_buffer.format); + total_frames += data.numberOfFrames; + } + + assert_true(base_buffer.format == 'f32' || base_buffer.format == 'f32-planar'); + + if (base_buffer.format == 'f32') + return join_interleaved_data(audio_data_array, total_frames); + + // The format is 'FLTP'. + return join_planar_data(audio_data_array, total_frames); +} + +function join_interleaved_data(audio_data_array, total_frames) { + let base_data = audio_data_array[0]; + let channels = base_data.numberOfChannels; + let total_samples = total_frames * channels; + + let result = new Float32Array(total_samples); + + let copy_dest = new Float32Array(base_data.numberOfFrames * channels); + + // Copy all the interleaved data. + let position = 0; + for (const data of audio_data_array) { + let samples = data.numberOfFrames * channels; + if (copy_dest.length < samples) + copy_dest = new Float32Array(samples); + + data.copyTo(copy_dest, {planeIndex: 0}); + result.set(copy_dest, position); + position += samples; + } + + assert_equals(position, total_samples); + + return result; +} + +function join_planar_data(audio_data_array, total_frames) { + let base_frames = audio_data_array[0].numberOfFrames; + let channels = audio_data_array[0].numberOfChannels; + let result = new Float32Array(total_frames*channels); + let copyDest = new Float32Array(base_frames); + + // Merge all samples and lay them out according to the FLTP memory layout. + let position = 0; + for (let ch = 0; ch < channels; ch++) { + for (const data of audio_data_array) { + data.copyTo(copyDest, { planeIndex: ch}); + result.set(copyDest, position); + position += data.numberOfFrames; + } + } + assert_equals(position, total_frames * channels); + + return result; +} + +promise_test(async t => { + let sample_rate = 48000; + let total_duration_s = 1; + let data_count = 10; + let outputs = []; + let init = { + error: e => { + assert_unreached("error: " + e); + }, + output: chunk => { + outputs.push(chunk); + } + }; + + let encoder = new AudioEncoder(init); + + assert_equals(encoder.state, "unconfigured"); + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 256000 //256kbit + }; + + encoder.configure(config); + + let timestamp_us = 0; + let data_duration_s = total_duration_s / data_count; + let data_length = data_duration_s * config.sampleRate; + for (let i = 0; i < data_count; i++) { + let data = make_audio_data(timestamp_us, config.numberOfChannels, + config.sampleRate, data_length); + encoder.encode(data); + data.close(); + timestamp_us += data_duration_s * 1_000_000; + } + await encoder.flush(); + encoder.close(); + assert_greater_than_equal(outputs.length, data_count); + assert_equals(outputs[0].timestamp, 0, "first chunk timestamp"); + let total_encoded_duration = 0 + for (chunk of outputs) { + assert_greater_than(chunk.byteLength, 0); + assert_greater_than_equal(timestamp_us, chunk.timestamp); + assert_greater_than(chunk.duration, 0); + total_encoded_duration += chunk.duration; + } + + // The total duration might be padded with silence. + assert_greater_than_equal( + total_encoded_duration, total_duration_s * 1_000_000); +}, 'Simple audio encoding'); + +promise_test(async t => { + let outputs = 0; + let init = getDefaultCodecInit(t); + let firstOutput = new Promise(resolve => { + init.output = (chunk, metadata) => { + outputs++; + assert_equals(outputs, 1, 'outputs'); + encoder.reset(); + resolve(); + }; + }); + + let encoder = new AudioEncoder(init); + let config = { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 2, + bitrate: 256000 // 256kbit + }; + encoder.configure(config); + + let frame_count = 1024; + let frame1 = make_audio_data( + 0, config.numberOfChannels, config.sampleRate, frame_count); + let frame2 = make_audio_data( + frame_count / config.sampleRate, config.numberOfChannels, + config.sampleRate, frame_count); + t.add_cleanup(() => { + frame1.close(); + frame2.close(); + }); + + encoder.encode(frame1); + encoder.encode(frame2); + const flushDone = encoder.flush(); + + // Wait for the first output, then reset. + await firstOutput; + + // 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 => { + let sample_rate = 48000; + let total_duration_s = 1; + let data_count = 10; + let outputs = []; + let init = { + error: e => { + assert_unreached('error: ' + e); + }, + output: chunk => { + outputs.push(chunk); + } + }; + + let encoder = new AudioEncoder(init); + + assert_equals(encoder.state, 'unconfigured'); + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 256000 // 256kbit + }; + + encoder.configure(config); + + let timestamp_us = -10000; + let data = make_audio_data( + timestamp_us, config.numberOfChannels, config.sampleRate, 10000); + encoder.encode(data); + data.close(); + await encoder.flush(); + encoder.close(); + assert_greater_than_equal(outputs.length, 1); + assert_equals(outputs[0].timestamp, -10000, 'first chunk timestamp'); + for (chunk of outputs) { + assert_greater_than(chunk.byteLength, 0); + assert_greater_than_equal(chunk.timestamp, timestamp_us); + } +}, 'Encode audio with negative timestamp'); + +async function checkEncodingError(t, config, good_data, bad_data) { + let support = await AudioEncoder.isConfigSupported(config); + assert_true(support.supported) + config = support.config; + + const callbacks = {}; + let errors = 0; + let gotError = new Promise(resolve => callbacks.error = e => { + errors++; + resolve(e); + }); + + let outputs = 0; + callbacks.output = chunk => { + outputs++; + }; + + let encoder = new AudioEncoder(callbacks); + encoder.configure(config); + for (let data of good_data) { + encoder.encode(data); + data.close(); + } + await encoder.flush(); + + let txt_config = "sampleRate: " + config.sampleRate + + " numberOfChannels: " + config.numberOfChannels; + assert_equals(errors, 0, txt_config); + assert_greater_than(outputs, 0); + outputs = 0; + + encoder.encode(bad_data); + await promise_rejects_dom(t, 'EncodingError', encoder.flush().catch((e) => { + assert_equals(errors, 1); + throw e; + })); + + assert_equals(outputs, 0); + let e = await gotError; + assert_true(e instanceof DOMException); + assert_equals(e.name, 'EncodingError'); + assert_equals(encoder.state, 'closed', 'state'); +} + +function channelNumberVariationTests() { + let sample_rate = 48000; + for (let channels = 1; channels <= 2; channels++) { + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: channels, + bitrate: 128000 + }; + + let ts = 0; + let length = sample_rate / 10; + let data1 = make_audio_data(ts, channels, sample_rate, length); + + ts += Math.floor(data1.duration / 1000000); + let data2 = make_audio_data(ts, channels, sample_rate, length); + ts += Math.floor(data2.duration / 1000000); + + let bad_data = make_audio_data(ts, channels + 1, sample_rate, length); + promise_test( + async t => checkEncodingError(t, config, [data1, data2], bad_data), + 'Channel number variation: ' + channels); + } +} +channelNumberVariationTests(); + +function sampleRateVariationTests() { + let channels = 1 + for (let sample_rate = 3000; sample_rate < 96000; sample_rate += 10000) { + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: channels, + bitrate: 128000 + }; + + let ts = 0; + let length = sample_rate / 10; + let data1 = make_audio_data(ts, channels, sample_rate, length); + + ts += Math.floor(data1.duration / 1000000); + let data2 = make_audio_data(ts, channels, sample_rate, length); + ts += Math.floor(data2.duration / 1000000); + + let bad_data = make_audio_data(ts, channels, sample_rate + 333, length); + promise_test( + async t => checkEncodingError(t, config, [data1, data2], bad_data), + 'Sample rate variation: ' + sample_rate); + } +} +sampleRateVariationTests(); + +promise_test(async t => { + let sample_rate = 48000; + let total_duration_s = 1; + let data_count = 10; + let input_data = []; + let output_data = []; + + let decoder_init = { + error: t.unreached_func("Decode error"), + output: data => { + output_data.push(data); + } + }; + let decoder = new AudioDecoder(decoder_init); + + let encoder_init = { + error: t.unreached_func("Encoder error"), + output: (chunk, metadata) => { + let config = metadata.decoderConfig; + if (config) + decoder.configure(config); + decoder.decode(chunk); + } + }; + let encoder = new AudioEncoder(encoder_init); + + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 256000, //256kbit + }; + encoder.configure(config); + + let timestamp_us = 0; + const data_duration_s = total_duration_s / data_count; + const data_length = data_duration_s * config.sampleRate; + for (let i = 0; i < data_count; i++) { + let data = make_audio_data(timestamp_us, config.numberOfChannels, + config.sampleRate, data_length); + input_data.push(data); + encoder.encode(data); + timestamp_us += data_duration_s * 1_000_000; + } + await encoder.flush(); + encoder.close(); + await decoder.flush(); + decoder.close(); + + + let total_input = join_audio_data(input_data); + let frames_per_plane = total_input.length / config.numberOfChannels; + + let total_output = join_audio_data(output_data); + + let base_input = input_data[0]; + let base_output = output_data[0]; + + // TODO: Convert formats to simplify conversions, once + // https://github.com/w3c/webcodecs/issues/232 is resolved. + assert_equals(base_input.format, "f32-planar"); + assert_equals(base_output.format, "f32"); + + assert_equals(base_output.numberOfChannels, config.numberOfChannels); + assert_equals(base_output.sampleRate, sample_rate); + + // Output can be slightly longer that the input due to padding + assert_greater_than_equal(total_output.length, total_input.length); + + // Compare waveform before and after encoding + for (let channel = 0; channel < base_input.numberOfChannels; channel++) { + + let plane_start = channel * frames_per_plane; + let input_plane = total_input.slice( + plane_start, plane_start + frames_per_plane); + + for (let i = 0; i < base_input.numberOfFrames; i += 10) { + // Instead of de-interleaving the data, directly look into |total_output| + // for the sample we are interested in. + let ouput_index = i * base_input.numberOfChannels + channel; + + // Checking only every 10th sample to save test time in slow + // configurations like MSAN etc. + assert_approx_equals( + input_plane[i], total_output[ouput_index], 0.5, + 'Difference between input and output is too large.' + + ' index: ' + i + ' channel: ' + channel + + ' input: ' + input_plane[i] + + ' output: ' + total_output[ouput_index]); + } + } + +}, 'Encoding and decoding'); + +promise_test(async t => { + let output_count = 0; + let encoder_config = { + codec: 'opus', + sampleRate: 24000, + numberOfChannels: 1, + bitrate: 96000 + }; + let decoder_config = null; + + let init = { + error: t.unreached_func("Encoder error"), + output: (chunk, metadata) => { + let config = metadata.decoderConfig; + // Only the first invocation of the output callback is supposed to have + // a |config| in it. + output_count++; + if (output_count == 1) { + assert_equals(typeof config, "object"); + decoder_config = config; + } else { + assert_equals(config, undefined); + } + } + }; + + let encoder = new AudioEncoder(init); + encoder.configure(encoder_config); + + let large_data = make_audio_data(0, encoder_config.numberOfChannels, + encoder_config.sampleRate, encoder_config.sampleRate); + encoder.encode(large_data); + await encoder.flush(); + + // Large data produced more than one output, and we've got decoder_config + assert_greater_than(output_count, 1); + assert_not_equals(decoder_config, null); + assert_equals(decoder_config.codec, encoder_config.codec); + assert_equals(decoder_config.sampleRate, encoder_config.sampleRate); + assert_equals(decoder_config.numberOfChannels, encoder_config.numberOfChannels); + + // Check that description start with 'Opus' + let extra_data = new Uint8Array(decoder_config.description); + assert_equals(extra_data[0], 0x4f); + assert_equals(extra_data[1], 0x70); + assert_equals(extra_data[2], 0x75); + assert_equals(extra_data[3], 0x73); + + decoder_config = null; + output_count = 0; + encoder_config.bitrate = 256000; + encoder.configure(encoder_config); + encoder.encode(large_data); + await encoder.flush(); + + // After reconfiguring encoder should produce decoder config again + assert_greater_than(output_count, 1); + assert_not_equals(decoder_config, null); + assert_not_equals(decoder_config.description, null); + encoder.close(); +}, "Emit decoder config and extra data."); + +promise_test(async t => { + let sample_rate = 48000; + let total_duration_s = 1; + let data_count = 100; + let init = getDefaultCodecInit(t); + init.output = (chunk, metadata) => {} + + let encoder = new AudioEncoder(init); + + // No encodes yet. + assert_equals(encoder.encodeQueueSize, 0); + + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 256000 //256kbit + }; + encoder.configure(config); + + // Still no encodes. + assert_equals(encoder.encodeQueueSize, 0); + + let datas = []; + let timestamp_us = 0; + let data_duration_s = total_duration_s / data_count; + let data_length = data_duration_s * config.sampleRate; + for (let i = 0; i < data_count; i++) { + let data = make_audio_data(timestamp_us, config.numberOfChannels, + config.sampleRate, data_length); + datas.push(data); + timestamp_us += data_duration_s * 1_000_000; + } + + let lastDequeueSize = Infinity; + encoder.ondequeue = () => { + assert_greater_than(lastDequeueSize, 0, "Dequeue event after queue empty"); + assert_greater_than(lastDequeueSize, encoder.encodeQueueSize, + "Dequeue event without decreased queue size"); + lastDequeueSize = encoder.encodeQueueSize; + }; + + for (let data of datas) + encoder.encode(data); + + assert_greater_than_equal(encoder.encodeQueueSize, 0); + assert_less_than_equal(encoder.encodeQueueSize, data_count); + + await encoder.flush(); + // We can guarantee that all encodes are processed after a flush. + assert_equals(encoder.encodeQueueSize, 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 encodes. + lastDequeueSize = Infinity; + + for (let data of datas) { + encoder.encode(data); + data.close(); + } + + assert_greater_than_equal(encoder.encodeQueueSize, 0); + encoder.reset(); + assert_equals(encoder.encodeQueueSize, 0); +}, 'encodeQueueSize test'); + +const testOpusEncoderConfigs = [ + { + comment: 'Empty Opus config', + opus: {}, + }, + { + comment: 'Opus with frameDuration', + opus: {frameDuration: 2500}, + }, + { + comment: 'Opus with complexity', + opus: {complexity: 10}, + }, + { + comment: 'Opus with useinbandfec', + opus: { + packetlossperc: 15, + useinbandfec: true, + }, + }, + { + comment: 'Opus with usedtx', + opus: {usedtx: true}, + }, + { + comment: 'Opus mixed parameters', + opus: { + frameDuration: 40000, + complexity: 0, + packetlossperc: 10, + useinbandfec: true, + usedtx: true, + }, + } +]; + +testOpusEncoderConfigs.forEach(entry => { + promise_test(async t => { + let sample_rate = 48000; + let total_duration_s = 0.5; + let data_count = 10; + let outputs = []; + let init = { + error: e => { + assert_unreached('error: ' + e); + }, + output: chunk => { + outputs.push(chunk); + } + }; + + let encoder = new AudioEncoder(init); + + assert_equals(encoder.state, 'unconfigured'); + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 256000, // 256kbit + opus: entry.opus, + }; + + encoder.configure(config); + + let timestamp_us = 0; + let data_duration_s = total_duration_s / data_count; + let data_length = data_duration_s * config.sampleRate; + for (let i = 0; i < data_count; i++) { + let data = make_audio_data( + timestamp_us, config.numberOfChannels, config.sampleRate, + data_length); + encoder.encode(data); + data.close(); + timestamp_us += data_duration_s * 1_000_000; + } + + // Encoders might output an extra buffer of silent padding. + let padding_us = data_duration_s * 1_000_000; + + await encoder.flush(); + encoder.close(); + assert_greater_than_equal(outputs.length, data_count); + assert_equals(outputs[0].timestamp, 0, 'first chunk timestamp'); + let total_encoded_duration = 0 + for (chunk of outputs) { + assert_greater_than(chunk.byteLength, 0, 'chunk byteLength'); + assert_greater_than_equal( + timestamp_us + padding_us, chunk.timestamp, 'chunk timestamp'); + assert_greater_than(chunk.duration, 0, 'chunk duration'); + total_encoded_duration += chunk.duration; + } + + // The total duration might be padded with silence. + assert_greater_than_equal( + total_encoded_duration, total_duration_s * 1_000_000); + }, 'Test encoding Opus with additional parameters: ' + entry.comment); +}); 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 new file mode 100644 index 0000000000..92513be087 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/audioDecoder-codec-specific.https.any.js @@ -0,0 +1,371 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js +// META: variant=?adts_aac +// META: variant=?mp4_aac +// META: variant=?mp3 +// META: variant=?opus +// META: variant=?pcm_alaw +// META: variant=?pcm_mulaw + +const ADTS_AAC_DATA = { + src: 'sfx.adts', + config: { + codec: 'mp4a.40.2', + sampleRate: 48000, + numberOfChannels: 1, + }, + chunks: [ + {offset: 0, size: 248}, {offset: 248, size: 280}, {offset: 528, size: 258}, + {offset: 786, size: 125}, {offset: 911, size: 230}, + {offset: 1141, size: 148}, {offset: 1289, size: 224}, + {offset: 1513, size: 166}, {offset: 1679, size: 216}, + {offset: 1895, size: 183} + ], + duration: 24000 +}; + +const MP3_DATA = { + src: 'sfx.mp3', + config: { + codec: 'mp3', + sampleRate: 48000, + numberOfChannels: 1, + }, + chunks: [ + {offset: 333, size: 288}, {offset: 621, size: 288}, + {offset: 909, size: 288}, {offset: 1197, size: 288}, + {offset: 1485, size: 288}, {offset: 1773, size: 288}, + {offset: 2061, size: 288}, {offset: 2349, size: 288}, + {offset: 2637, size: 288}, {offset: 2925, size: 288} + ], + duration: 24000 +}; + +const MP4_AAC_DATA = { + src: 'sfx-aac.mp4', + config: { + codec: 'mp4a.40.2', + sampleRate: 48000, + numberOfChannels: 1, + description: {offset: 2552, size: 5}, + }, + 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: { + codec: 'opus', + sampleRate: 48000, + numberOfChannels: 1, + description: {offset: 28, size: 19}, + }, + chunks: [ + {offset: 185, size: 450}, {offset: 635, size: 268}, + {offset: 903, size: 285}, {offset: 1188, size: 296}, + {offset: 1484, size: 287}, {offset: 1771, size: 308}, + {offset: 2079, size: 289}, {offset: 2368, size: 286}, + {offset: 2654, size: 296}, {offset: 2950, size: 294} + ], + duration: 20000 +}; + +const PCM_ALAW_DATA = { + src: 'sfx-alaw.wav', + config: { + codec: 'alaw', + sampleRate: 48000, + numberOfChannels: 1, + }, + // Any arbitrary grouping should work. + chunks: [ + {offset: 0, size: 2048}, {offset: 2048, size: 2048}, + {offset: 4096, size: 2048}, {offset: 6144, size: 2048}, + {offset: 8192, size: 2048}, {offset: 10240, size: 92} + ], + duration: 35555 +}; + +const PCM_MULAW_DATA = { + src: 'sfx-mulaw.wav', + config: { + codec: 'ulaw', + sampleRate: 48000, + numberOfChannels: 1, + }, + + // Any arbitrary grouping should work. + chunks: [ + {offset: 0, size: 2048}, {offset: 2048, size: 2048}, + {offset: 4096, size: 2048}, {offset: 6144, size: 2048}, + {offset: 8192, size: 2048}, {offset: 10240, size: 92} + ], + duration: 35555 +}; + +// 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 = { + '?adts_aac': ADTS_AAC_DATA, + '?mp3': MP3_DATA, + '?mp4_aac': MP4_AAC_DATA, + '?opus': OPUS_DATA, + '?pcm_alaw': PCM_ALAW_DATA, + '?pcm_mulaw': PCM_MULAW_DATA, + }[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 = 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/tests/webcodecs/av1.mp4 b/testing/web-platform/tests/webcodecs/av1.mp4 new file mode 100644 index 0000000000..8d2a7acdb8 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/av1.mp4 differ diff --git a/testing/web-platform/tests/webcodecs/chunk-serialization.any.js b/testing/web-platform/tests/webcodecs/chunk-serialization.any.js new file mode 100644 index 0000000000..821a71170d --- /dev/null +++ b/testing/web-platform/tests/webcodecs/chunk-serialization.any.js @@ -0,0 +1,80 @@ +// META: global=window +// META: script=/common/media.js +// META: script=/webcodecs/utils.js + +var defaultAudioInit = { + type: 'key', + timestamp: 1234, + duration: 9876, + data: new Uint8Array([5, 6, 7, 8]) +}; + +var defaultVideoInit = { + type: 'key', + timestamp: 1234, + duration: 5678, + data: new Uint8Array([9, 10, 11, 12]) +}; + +function createDefaultChunk(type, init) { + return type == 'audio' ? new EncodedAudioChunk(init) : + new EncodedVideoChunk(init); +} + +function runTest(t, type) { + let defaultInit = type == 'audio' ? defaultAudioInit : defaultVideoInit; + let originalData = createDefaultChunk(type, defaultInit); + + let channel = new MessageChannel(); + let localPort = channel.port1; + let externalPort = channel.port2; + + externalPort.onmessage = t.step_func((e) => { + let newData = e.data; + + // We should have a valid deserialized buffer. + assert_equals(newData.type, defaultInit.type, 'type'); + assert_equals(newData.duration, defaultInit.duration, 'duration'); + assert_equals(newData.timestamp, defaultInit.timestamp, 'timestamp'); + assert_equals( + newData.byteLength, defaultInit.data.byteLength, 'byteLength'); + + const originalData_copyDest = new Uint8Array(defaultInit.data); + const newData_copyDest = new Uint8Array(defaultInit.data); + + originalData.copyTo(originalData_copyDest); + newData.copyTo(newData_copyDest); + + for (var i = 0; i < newData_copyDest.length; ++i) { + assert_equals( + newData_copyDest[i], originalData_copyDest[i], `data (i=${i})`); + } + + externalPort.postMessage('Done'); + }) + + localPort.onmessage = t.step_func_done((e) => { + assert_equals(originalData.type, defaultInit.type, 'type'); + assert_equals(originalData.duration, defaultInit.duration, 'duration'); + assert_equals(originalData.timestamp, defaultInit.timestamp, 'timestamp'); + assert_equals( + originalData.byteLength, defaultInit.data.byteLength, 'byteLength'); + }) + + localPort.postMessage(originalData); +} + +async_test(t => { + runTest(t, 'audio'); +}, 'Verify EncodedAudioChunk is serializable.'); + + +async_test(t => { + runTest(t, 'video'); +}, 'Verify EncodedVideoChunk is serializable.'); + +test(() => { + const chunk = createDefaultChunk("video", defaultVideoInit); + if (window.history) + assert_throws_dom("DataCloneError", () => history.pushState({ chunk }, null)); +}, "Verify EncodedVideoChunk cannot be stored"); diff --git a/testing/web-platform/tests/webcodecs/encoded-audio-chunk.any.js b/testing/web-platform/tests/webcodecs/encoded-audio-chunk.any.js new file mode 100644 index 0000000000..1ada120e4d --- /dev/null +++ b/testing/web-platform/tests/webcodecs/encoded-audio-chunk.any.js @@ -0,0 +1,45 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +test(t => { + let chunk = new EncodedAudioChunk({type: 'key', + timestamp: 10, + duration: 123, + data: new Uint8Array([0x0A, 0x0B, 0x0C])}); + assert_equals(chunk.type, 'key', 'type'); + assert_equals(chunk.timestamp, 10, 'timestamp'); + assert_equals(chunk.duration, 123, 'duration'); + assert_equals(chunk.byteLength, 3, 'byteLength'); + let copyDest = new Uint8Array(3); + chunk.copyTo(copyDest); + assert_equals(copyDest[0], 0x0A, 'copyDest[0]'); + assert_equals(copyDest[1], 0x0B, 'copyDest[1]'); + assert_equals(copyDest[2], 0x0C, 'copyDest[2]'); + + // Make another chunk with different values for good measure. + chunk = new EncodedAudioChunk({type: 'delta', + timestamp: 100, + data: new Uint8Array([0x00, 0x01])}); + assert_equals(chunk.type, 'delta', 'type'); + assert_equals(chunk.timestamp, 100, 'timestamp'); + assert_equals(chunk.duration, null, 'missing duration'); + assert_equals(chunk.byteLength, 2, 'byteLength'); + copyDest = new Uint8Array(2); + chunk.copyTo(copyDest); + assert_equals(copyDest[0], 0x00, 'copyDest[0]'); + assert_equals(copyDest[1], 0x01, 'copyDest[1]'); +}, 'Test we can construct an EncodedAudioChunk.'); + +test(t => { + let chunk = new EncodedAudioChunk({type: 'delta', + timestamp: 100, + data: new Uint8Array([0x00, 0x01, 0x02])}); + assert_throws_js( + TypeError, + () => chunk.copyTo(new Uint8Array(2)), 'destination is not large enough'); + + const detached = makeDetachedArrayBuffer(); + assert_throws_js( + TypeError, + () => chunk.copyTo(detached), 'destination is detached'); +}, 'Test copyTo() exception if destination invalid'); diff --git a/testing/web-platform/tests/webcodecs/encoded-audio-chunk.crossOriginIsolated.https.any.js b/testing/web-platform/tests/webcodecs/encoded-audio-chunk.crossOriginIsolated.https.any.js new file mode 100644 index 0000000000..7063d85887 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/encoded-audio-chunk.crossOriginIsolated.https.any.js @@ -0,0 +1,29 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +function testSharedArrayBufferEncodedAudioChunk(useView) { + let data = new SharedArrayBuffer(3); + let view = new Uint8Array(data); + view[0] = 0x0A; + view[1] = 0x0B; + view[2] = 0x0C; + + let chunk = new EncodedAudioChunk( + {type: 'key', timestamp: 10, duration: 123, data: useView ? view : data}); + assert_equals(chunk.byteLength, 3, 'byteLength'); + + let copyDest = new SharedArrayBuffer(3); + let destView = new Uint8Array(copyDest); + chunk.copyTo(useView ? destView : copyDest); + assert_equals(destView[0], 0x0A, 'copyDest[0]'); + assert_equals(destView[1], 0x0B, 'copyDest[1]'); + assert_equals(destView[2], 0x0C, 'copyDest[2]'); +} + +test(t => { + testSharedArrayBufferEncodedAudioChunk(/*useView=*/ false); +}, 'Test construction and copyTo() using a SharedArrayBuffer'); + +test(t => { + testSharedArrayBufferEncodedAudioChunk(/*useView=*/ true); +}, 'Test construction and copyTo() using a Uint8Array(SharedArrayBuffer)'); diff --git a/testing/web-platform/tests/webcodecs/encoded-audio-chunk.crossOriginIsolated.https.any.js.headers b/testing/web-platform/tests/webcodecs/encoded-audio-chunk.crossOriginIsolated.https.any.js.headers new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/encoded-audio-chunk.crossOriginIsolated.https.any.js.headers @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/testing/web-platform/tests/webcodecs/encoded-video-chunk.any.js b/testing/web-platform/tests/webcodecs/encoded-video-chunk.any.js new file mode 100644 index 0000000000..9b60e59a79 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/encoded-video-chunk.any.js @@ -0,0 +1,56 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +test(t => { + let chunk = new EncodedVideoChunk({type: 'key', + timestamp: 10, + duration: 300, + data: new Uint8Array([0x0A, 0x0B, 0x0C])}); + assert_equals(chunk.type, 'key', 'type'); + assert_equals(chunk.timestamp, 10, 'timestamp'); + assert_equals(chunk.duration, 300, 'duration'); + assert_equals(chunk.byteLength, 3, 'byteLength'); + let copyDest = new Uint8Array(3); + chunk.copyTo(copyDest); + assert_equals(copyDest[0], 0x0A, 'copyDest[0]'); + assert_equals(copyDest[1], 0x0B, 'copyDest[1]'); + assert_equals(copyDest[2], 0x0C, 'copyDest[2]'); + + // Make another chunk with different values for good measure. + chunk = new EncodedVideoChunk({type: 'delta', + timestamp: 100, + data: new Uint8Array([0x00, 0x01])}); + assert_equals(chunk.type, 'delta', 'type'); + assert_equals(chunk.timestamp, 100, 'timestamp'); + assert_equals(chunk.duration, null, 'duration'); + assert_equals(chunk.byteLength, 2, 'byteLength'); + copyDest = new Uint8Array(2); + chunk.copyTo(copyDest); + assert_equals(copyDest[0], 0x00, 'copyDest[0]'); + assert_equals(copyDest[1], 0x01, 'copyDest[1]'); +}, 'Test we can construct an EncodedVideoChunk.'); + +test(t => { + let chunk = new EncodedVideoChunk({type: 'delta', + timestamp: 100, + data: new Uint8Array([0x00, 0x01, 0x02])}); + assert_throws_js( + TypeError, + () => chunk.copyTo(new Uint8Array(2)), 'destination is not large enough'); + + const detached = makeDetachedArrayBuffer(); + assert_throws_js( + TypeError, + () => chunk.copyTo(detached), 'destination is detached'); +}, 'Test copyTo() exception if destiation invalid'); + +test(t => { + let chunk = new EncodedVideoChunk({type: 'key', + timestamp: 10, + duration: 300, + data: new Uint8Array()}); + assert_equals(chunk.byteLength, 0, 'byteLength'); + let copyDest = new Uint8Array(); + chunk.copyTo(copyDest); + assert_equals(copyDest.length, 0, 'copyDest.length'); +}, 'Test we can construct an zero-sized EncodedVideoChunk.'); diff --git a/testing/web-platform/tests/webcodecs/encoded-video-chunk.crossOriginIsolated.https.any.js b/testing/web-platform/tests/webcodecs/encoded-video-chunk.crossOriginIsolated.https.any.js new file mode 100644 index 0000000000..7f414fec1f --- /dev/null +++ b/testing/web-platform/tests/webcodecs/encoded-video-chunk.crossOriginIsolated.https.any.js @@ -0,0 +1,29 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +function testSharedArrayBufferEncodedVideoChunk(useView) { + let data = new SharedArrayBuffer(3); + let view = new Uint8Array(data); + view[0] = 0x0A; + view[1] = 0x0B; + view[2] = 0x0C; + + let chunk = new EncodedVideoChunk( + {type: 'key', timestamp: 10, duration: 123, data: useView ? view : data}); + assert_equals(chunk.byteLength, 3, 'byteLength'); + + let copyDest = new SharedArrayBuffer(3); + let destView = new Uint8Array(copyDest); + chunk.copyTo(useView ? destView : copyDest); + assert_equals(destView[0], 0x0A, 'copyDest[0]'); + assert_equals(destView[1], 0x0B, 'copyDest[1]'); + assert_equals(destView[2], 0x0C, 'copyDest[2]'); +} + +test(t => { + testSharedArrayBufferEncodedVideoChunk(/*useView=*/ false); +}, 'Test construction and copyTo() using a SharedArrayBuffer'); + +test(t => { + testSharedArrayBufferEncodedVideoChunk(/*useView=*/ true); +}, 'Test construction and copyTo() using a Uint8Array(SharedArrayBuffer)'); diff --git a/testing/web-platform/tests/webcodecs/encoded-video-chunk.crossOriginIsolated.https.any.js.headers b/testing/web-platform/tests/webcodecs/encoded-video-chunk.crossOriginIsolated.https.any.js.headers new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/encoded-video-chunk.crossOriginIsolated.https.any.js.headers @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/testing/web-platform/tests/webcodecs/encodedVideoChunk-serialization.crossAgentCluster.helper.html b/testing/web-platform/tests/webcodecs/encodedVideoChunk-serialization.crossAgentCluster.helper.html new file mode 100644 index 0000000000..424ce927f9 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/encodedVideoChunk-serialization.crossAgentCluster.helper.html @@ -0,0 +1,23 @@ + + + +

+
+ + + diff --git a/testing/web-platform/tests/webcodecs/encodedVideoChunk-serialization.crossAgentCluster.https.html b/testing/web-platform/tests/webcodecs/encodedVideoChunk-serialization.crossAgentCluster.https.html new file mode 100644 index 0000000000..fb104a3a1b --- /dev/null +++ b/testing/web-platform/tests/webcodecs/encodedVideoChunk-serialization.crossAgentCluster.https.html @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + diff --git a/testing/web-platform/tests/webcodecs/four-colors-flip.avif b/testing/web-platform/tests/webcodecs/four-colors-flip.avif new file mode 100644 index 0000000000..eb08106160 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors-flip.avif differ diff --git a/testing/web-platform/tests/webcodecs/four-colors-flip.gif b/testing/web-platform/tests/webcodecs/four-colors-flip.gif new file mode 100644 index 0000000000..ff7b69a0e4 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors-flip.gif differ diff --git a/testing/web-platform/tests/webcodecs/four-colors-full-range-bt2020-pq-444-10bpc.avif b/testing/web-platform/tests/webcodecs/four-colors-full-range-bt2020-pq-444-10bpc.avif new file mode 100644 index 0000000000..512a2b855e Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors-full-range-bt2020-pq-444-10bpc.avif differ diff --git a/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.avif b/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.avif new file mode 100644 index 0000000000..925477b04c Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.avif differ diff --git a/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.jpg b/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.jpg new file mode 100644 index 0000000000..9ce1f1abbe Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.jpg differ diff --git a/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.webp b/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.webp new file mode 100644 index 0000000000..8086d0140a Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors-limited-range-420-8bpc.webp differ diff --git a/testing/web-platform/tests/webcodecs/four-colors-limited-range-422-8bpc.avif b/testing/web-platform/tests/webcodecs/four-colors-limited-range-422-8bpc.avif new file mode 100644 index 0000000000..e348bade31 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors-limited-range-422-8bpc.avif differ diff --git a/testing/web-platform/tests/webcodecs/four-colors-limited-range-444-8bpc.avif b/testing/web-platform/tests/webcodecs/four-colors-limited-range-444-8bpc.avif new file mode 100644 index 0000000000..300cd1ca97 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors-limited-range-444-8bpc.avif differ diff --git a/testing/web-platform/tests/webcodecs/four-colors.avif b/testing/web-platform/tests/webcodecs/four-colors.avif new file mode 100644 index 0000000000..38ed02e69d Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors.avif differ diff --git a/testing/web-platform/tests/webcodecs/four-colors.gif b/testing/web-platform/tests/webcodecs/four-colors.gif new file mode 100644 index 0000000000..d189e98900 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors.gif differ diff --git a/testing/web-platform/tests/webcodecs/four-colors.jpg b/testing/web-platform/tests/webcodecs/four-colors.jpg new file mode 100644 index 0000000000..f888e8e844 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors.jpg differ diff --git a/testing/web-platform/tests/webcodecs/four-colors.mp4 b/testing/web-platform/tests/webcodecs/four-colors.mp4 new file mode 100644 index 0000000000..95a7df6411 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors.mp4 differ diff --git a/testing/web-platform/tests/webcodecs/four-colors.png b/testing/web-platform/tests/webcodecs/four-colors.png new file mode 100644 index 0000000000..2a8b31c426 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors.png differ diff --git a/testing/web-platform/tests/webcodecs/four-colors.webp b/testing/web-platform/tests/webcodecs/four-colors.webp new file mode 100644 index 0000000000..f7dd40bee9 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/four-colors.webp differ diff --git a/testing/web-platform/tests/webcodecs/full-cycle-test.https.any.js b/testing/web-platform/tests/webcodecs/full-cycle-test.https.any.js new file mode 100644 index 0000000000..8e1ae65bdc --- /dev/null +++ b/testing/web-platform/tests/webcodecs/full-cycle-test.https.any.js @@ -0,0 +1,213 @@ +// META: timeout=long +// META: global=window,dedicatedworker +// META: script=/webcodecs/video-encoder-utils.js +// META: variant=?av1 +// META: variant=?av1_444_high +// META: variant=?vp8 +// META: variant=?vp9_p0 +// META: variant=?vp9_p2 +// META: variant=?vp9_444_p1 +// META: variant=?vp9_444_p3 +// META: variant=?h264_avc +// META: variant=?h264_annexb +// META: variant=?h265_hevc +// META: variant=?h265_annexb + +var ENCODER_CONFIG = null; +promise_setup(async () => { + const config = { + '?av1': { + codec: 'av01.0.04M.08', + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-software', + }, + '?av1_444_high': { + codec: 'av01.1.04M.08.0.000', + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-software', + outputPixelFormat: 'I444', + }, + '?vp8': { + codec: 'vp8', + hasEmbeddedColorSpace: false, + hardwareAcceleration: 'prefer-software', + }, + '?vp9_p0': { + codec: 'vp09.00.10.08', + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-software', + }, + '?vp9_p2': { + codec: 'vp09.02.10.10', + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-software', + // TODO(https://github.com/w3c/webcodecs/issues/384): + // outputPixelFormat should be 'I420P10' + }, + '?vp9_444_p1': { + codec: 'vp09.01.10.08.03', + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-software', + outputPixelFormat: 'I444', + }, + '?vp9_444_p3': { + codec: 'vp09.03.10.10.03', + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-software', + // TODO(https://github.com/w3c/webcodecs/issues/384): + // outputPixelFormat should be 'I444P10' + }, + '?h264_avc': { + codec: 'avc1.42001E', + avc: {format: 'avc'}, + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-software', + }, + '?h264_annexb': { + codec: 'avc1.42001E', + avc: {format: 'annexb'}, + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-software', + }, + '?h265_hevc': { + codec: 'hvc1.1.6.L123.00', + hevc: {format: 'hevc'}, + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-hardware', + }, + '?h265_annexb': { + codec: 'hvc1.1.6.L123.00', + hevc: {format: 'annexb'}, + hasEmbeddedColorSpace: true, + hardwareAcceleration: 'prefer-hardware', + } + }[location.search]; + config.width = 320; + config.height = 200; + config.bitrate = 1000000; + config.bitrateMode = "constant"; + config.framerate = 30; + ENCODER_CONFIG = config; +}); + +async function runFullCycleTest(t, options) { + let encoder_config = { ...ENCODER_CONFIG }; + if (options.realTimeLatencyMode) { + encoder_config.latencyMode = 'realtime'; + } + let encoder_color_space = {}; + const w = encoder_config.width; + const h = encoder_config.height; + let next_ts = 0 + let frames_to_encode = 16; + let frames_encoded = 0; + let frames_decoded = 0; + + await checkEncoderSupport(t, encoder_config); + let decoder = new VideoDecoder({ + output(frame) { + t.add_cleanup(() => { frame.close() }); + + assert_equals(frame.visibleRect.width, w, "visibleRect.width"); + assert_equals(frame.visibleRect.height, h, "visibleRect.height"); + if (!options.realTimeLatencyMode) { + assert_equals(frame.timestamp, next_ts++, "decode timestamp"); + } + + if (ENCODER_CONFIG.outputPixelFormat) { + assert_equals( + frame.format, ENCODER_CONFIG.outputPixelFormat, + "decoded pixel format"); + } + + // The encoder is allowed to change the color space to satisfy the + // encoder when readback is needed to send the frame for encoding, but + // the decoder shouldn't change it after the fact. + assert_equals( + frame.colorSpace.primaries, encoder_color_space.primaries, + 'colorSpace.primaries'); + assert_equals( + frame.colorSpace.transfer, encoder_color_space.transfer, + 'colorSpace.transfer'); + assert_equals( + frame.colorSpace.matrix, encoder_color_space.matrix, + 'colorSpace.matrix'); + assert_equals( + frame.colorSpace.fullRange, encoder_color_space.fullRange, + 'colorSpace.fullRange'); + + frames_decoded++; + assert_true(validateBlackDots(frame, frame.timestamp), + "frame doesn't match. ts: " + frame.timestamp); + }, + error(e) { + assert_unreached(e.message); + } + }); + + let next_encode_ts = 0; + const encoder_init = { + output(chunk, metadata) { + let config = metadata.decoderConfig; + if (config) { + config.hardwareAcceleration = encoder_config.hardwareAcceleration; + encoder_color_space = config.colorSpace; + + // Removes the color space provided by the encoder so that color space + // information in the underlying bitstream is exposed during decode. + if (options.stripDecoderConfigColorSpace) + config.colorSpace = {}; + + decoder.configure(config); + } + decoder.decode(chunk); + frames_encoded++; + if (!options.realTimeLatencyMode) { + assert_equals(chunk.timestamp, next_encode_ts++, "encode timestamp"); + } + }, + error(e) { + assert_unreached(e.message); + } + }; + + let encoder = new VideoEncoder(encoder_init); + encoder.configure(encoder_config); + + for (let i = 0; i < frames_to_encode; i++) { + let frame = createDottedFrame(w, h, i); + + // Frames should have a valid color space when created from canvas. + assert_not_equals(frame.colorSpace.primaries, null, 'colorSpace.primaries'); + assert_not_equals(frame.colorSpace.transfer, null, 'colorSpace.transfer'); + assert_not_equals(frame.colorSpace.matrix, null, 'colorSpace.matrix'); + assert_not_equals(frame.colorSpace.fullRange, null, 'colorSpace.fullRange'); + + let keyframe = (i % 5 == 0); + encoder.encode(frame, { keyFrame: keyframe }); + frame.close(); + } + await encoder.flush(); + await decoder.flush(); + encoder.close(); + decoder.close(); + if (options.realTimeLatencyMode) { + assert_greater_than(frames_encoded, 0, "frames_encoded"); + } else { + assert_equals(frames_encoded, frames_to_encode, "frames_encoded"); + } + assert_equals(frames_decoded, frames_encoded, "frames_decoded"); +} + +promise_test(async t => { + return runFullCycleTest(t, {}); +}, 'Encoding and decoding cycle'); + +promise_test(async t => { + return runFullCycleTest(t, {realTimeLatencyMode: true}); +}, 'Encoding and decoding cycle with realtime latency mode'); + +promise_test(async t => { + if (ENCODER_CONFIG.hasEmbeddedColorSpace) + return runFullCycleTest(t, {stripDecoderConfigColorSpace: true}); +}, 'Encoding and decoding cycle w/ stripped color space'); diff --git a/testing/web-platform/tests/webcodecs/h264.annexb b/testing/web-platform/tests/webcodecs/h264.annexb new file mode 100644 index 0000000000..60c3b8cdec Binary files /dev/null and b/testing/web-platform/tests/webcodecs/h264.annexb differ diff --git a/testing/web-platform/tests/webcodecs/h264.mp4 b/testing/web-platform/tests/webcodecs/h264.mp4 new file mode 100644 index 0000000000..e0d6a6bedc Binary files /dev/null and b/testing/web-platform/tests/webcodecs/h264.mp4 differ diff --git a/testing/web-platform/tests/webcodecs/h265.annexb b/testing/web-platform/tests/webcodecs/h265.annexb new file mode 100644 index 0000000000..7f613c5e9c Binary files /dev/null and b/testing/web-platform/tests/webcodecs/h265.annexb differ diff --git a/testing/web-platform/tests/webcodecs/h265.mp4 b/testing/web-platform/tests/webcodecs/h265.mp4 new file mode 100644 index 0000000000..1c1adb0763 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/h265.mp4 differ diff --git a/testing/web-platform/tests/webcodecs/idlharness.https.any.js b/testing/web-platform/tests/webcodecs/idlharness.https.any.js new file mode 100644 index 0000000000..f1ed92a159 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/idlharness.https.any.js @@ -0,0 +1,61 @@ +// META: global=window,dedicatedworker +// META: script=/resources/WebIDLParser.js +// META: script=/resources/idlharness.js +// META: script=./utils.js +// META: timeout=long + +'use strict'; + +var defaultCodecInit = { + output: function() { + assert_unreached("unexpected output"); + }, + error: function() { + assert_unreached("unexpected error"); + }, +} + +var defaultAudioChunkInit = { + type: 'key', + timestamp: 1234, + duration: 9876, + data: new Uint8Array([5, 6, 7, 8]) +}; + +var defaultVideoChunkInit = { + type: 'key', + timestamp: 1234, + duration: 5678, + data: new Uint8Array([9, 10, 11, 12]) +}; + +idl_test(['webcodecs'], ['dom', 'html', 'webidl'], async idlArray => { + self.imageBody = + await fetch('four-colors.png').then(response => response.arrayBuffer()); + + let decoder = new ImageDecoder({data: self.imageBody, type: 'image/png'}); + await decoder.tracks.ready; + self.imageTracks = decoder.tracks.selectedTrack; + + idlArray.add_objects({ + AudioDecoder: [`new AudioDecoder(defaultCodecInit)`], + VideoDecoder: [`new VideoDecoder(defaultCodecInit)`], + AudioEncoder: [`new AudioEncoder(defaultCodecInit)`], + VideoEncoder: [`new VideoEncoder(defaultCodecInit)`], + EncodedAudioChunk: [`new EncodedAudioChunk(defaultAudioChunkInit)`], + EncodedVideoChunk: [`new EncodedVideoChunk(defaultVideoChunkInit)`], + AudioData: [`make_audio_data(1234, 2, 8000, 100)`], + VideoFrame: [ + `new VideoFrame(makeImageBitmap(32, 16), {timestamp: 100, duration: 33})` + ], + VideoColorSpace: [ + `new VideoColorSpace()`, + `new VideoColorSpace({primaries: 'bt709', transfer: 'bt709', matrix: 'bt709', fullRange: true})`, + ], + ImageDecoder: + [`new ImageDecoder({data: self.imageBody, type: 'image/png'})`], + ImageTrackList: + [`new ImageDecoder({data: self.imageBody, type: 'image/png'}).tracks`], + ImageTrack: [`self.imageTracks`], + }); +}); diff --git a/testing/web-platform/tests/webcodecs/image-decoder-disconnect-readable-stream-crash.https.html b/testing/web-platform/tests/webcodecs/image-decoder-disconnect-readable-stream-crash.https.html new file mode 100644 index 0000000000..d04c3e7019 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/image-decoder-disconnect-readable-stream-crash.https.html @@ -0,0 +1,12 @@ +Test ImageDecoder destruction w/ ReadableStream doesn't crash. + + + diff --git a/testing/web-platform/tests/webcodecs/image-decoder-image-orientation-none.https.html b/testing/web-platform/tests/webcodecs/image-decoder-image-orientation-none.https.html new file mode 100644 index 0000000000..2e555dbe21 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/image-decoder-image-orientation-none.https.html @@ -0,0 +1,88 @@ + +Test ImageDecoder outputs to a image-orientation: none canvas. + + + + + diff --git a/testing/web-platform/tests/webcodecs/image-decoder-utils.js b/testing/web-platform/tests/webcodecs/image-decoder-utils.js new file mode 100644 index 0000000000..eccab9b09a --- /dev/null +++ b/testing/web-platform/tests/webcodecs/image-decoder-utils.js @@ -0,0 +1,206 @@ +const kYellow = 0xFFFF00FF; +const kRed = 0xFF0000FF; +const kBlue = 0x0000FFFF; +const kGreen = 0x00FF00FF; + +function getColorName(color) { + switch (color) { + case kYellow: + return "Yellow"; + case kRed: + return "Red"; + case kBlue: + return "Blue"; + case kGreen: + return "Green"; + } + return "#" + color.toString(16); +} + +function toUInt32(pixelArray, roundForYuv) { + let p = pixelArray.data; + + // YUV to RGB conversion introduces some loss, so provide some leeway. + if (roundForYuv) { + const tolerance = 3; + for (var i = 0; i < p.length; ++i) { + if (p[i] >= 0xFF - tolerance) + p[i] = 0xFF; + if (p[i] <= 0x00 + tolerance) + p[i] = 0x00; + } + } + + return ((p[0] << 24) + (p[1] << 16) + (p[2] << 8) + p[3]) >>> 0; +} + +function flipMatrix(m) { + return m.map(row => row.reverse()); +} + +function rotateMatrix(m, count) { + for (var i = 0; i < count; ++i) + m = m[0].map((val, index) => m.map(row => row[index]).reverse()); + return m; +} + +function testFourColorsDecodeBuffer(buffer, mimeType, options = {}) { + var decoder = new ImageDecoder( + {data: buffer, type: mimeType, preferAnimation: options.preferAnimation}); + return decoder.decode().then(result => { + assert_equals(result.image.displayWidth, 320); + assert_equals(result.image.displayHeight, 240); + if (options.preferAnimation !== undefined) { + assert_greater_than(decoder.tracks.length, 1); + assert_equals( + options.preferAnimation, decoder.tracks.selectedTrack.animated); + } + if (options.yuvFormat !== undefined) + assert_equals(result.image.format, options.yuvFormat); + if (options.tolerance === undefined) + options.tolerance = 0; + + let canvas = new OffscreenCanvas( + result.image.displayWidth, result.image.displayHeight); + let ctx = canvas.getContext('2d'); + ctx.drawImage(result.image, 0, 0); + + let top_left = ctx.getImageData(0, 0, 1, 1); + let top_right = ctx.getImageData(result.image.displayWidth - 1, 0, 1, 1); + let bottom_left = ctx.getImageData(0, result.image.displayHeight - 1, 1, 1); + let left_corner = ctx.getImageData( + result.image.displayWidth - 1, result.image.displayHeight - 1, 1, 1); + + assert_array_approx_equals( + top_left.data, [0xFF, 0xFF, 0x00, 0xFF], options.tolerance, + 'top left corner is yellow'); + assert_array_approx_equals( + top_right.data, [0xFF, 0x00, 0x00, 0xFF], options.tolerance, + 'top right corner is red'); + assert_array_approx_equals( + bottom_left.data, [0x00, 0x00, 0xFF, 0xFF], options.tolerance, + 'bottom left corner is blue'); + assert_array_approx_equals( + left_corner.data, [0x00, 0xFF, 0x00, 0xFF], options.tolerance, + 'bottom right corner is green'); + }); +} + +function testFourColorDecodeWithExifOrientation(orientation, canvas, useYuv) { + return ImageDecoder.isTypeSupported('image/jpeg').then(support => { + assert_implements_optional( + support, 'Optional codec image/jpeg not supported.'); + const testFile = + useYuv ? 'four-colors-limited-range-420-8bpc.jpg' : 'four-colors.jpg'; + return fetch(testFile) + .then(response => { + return response.arrayBuffer(); + }) + .then(buffer => { + let u8buffer = new Uint8Array(buffer); + u8buffer[useYuv ? 0x31 : 0x1F] = + orientation; // Location derived via diff. + let decoder = new ImageDecoder({data: u8buffer, type: 'image/jpeg'}); + return decoder.decode(); + }) + .then(result => { + let respectOrientation = true; + if (canvas) + respectOrientation = canvas.style.imageOrientation != 'none'; + + let expectedWidth = 320; + let expectedHeight = 240; + if (orientation > 4 && respectOrientation) + [expectedWidth, expectedHeight] = [expectedHeight, expectedWidth]; + + if (respectOrientation) { + assert_equals(result.image.displayWidth, expectedWidth); + assert_equals(result.image.displayHeight, expectedHeight); + } else if (orientation > 4) { + assert_equals(result.image.displayHeight, expectedWidth); + assert_equals(result.image.displayWidth, expectedHeight); + } + + if (!canvas) { + canvas = new OffscreenCanvas( + result.image.displayWidth, result.image.displayHeight); + } else { + canvas.width = expectedWidth; + canvas.height = expectedHeight; + } + + let ctx = canvas.getContext('2d'); + ctx.drawImage(result.image, 0, 0); + + let matrix = [ + [kYellow, kRed], + [kBlue, kGreen], + ]; + if (respectOrientation) { + switch (orientation) { + case 1: // kOriginTopLeft, default + break; + case 2: // kOriginTopRight, mirror along y-axis + matrix = flipMatrix(matrix); + break; + case 3: // kOriginBottomRight, 180 degree rotation + matrix = rotateMatrix(matrix, 2); + break; + case 4: // kOriginBottomLeft, mirror along the x-axis + matrix = flipMatrix(rotateMatrix(matrix, 2)); + break; + case 5: // kOriginLeftTop, mirror along x-axis + 270 degree CW + // rotation + matrix = flipMatrix(rotateMatrix(matrix, 1)); + break; + case 6: // kOriginRightTop, 90 degree CW rotation + matrix = rotateMatrix(matrix, 1); + break; + case 7: // kOriginRightBottom, mirror along x-axis + 90 degree CW + // rotation + matrix = flipMatrix(rotateMatrix(matrix, 3)); + break; + case 8: // kOriginLeftBottom, 270 degree CW rotation + matrix = rotateMatrix(matrix, 3); + break; + default: + assert_between_inclusive( + orientation, 1, 8, 'unknown image orientation'); + break; + }; + } + + verifyFourColorsImage( + expectedWidth, expectedHeight, ctx, matrix, useYuv); + }); + }); +} + +function verifyFourColorsImage(width, height, ctx, matrix, isYuv) { + if (!matrix) { + matrix = [ + [kYellow, kRed], + [kBlue, kGreen], + ]; + } + + let expectedTopLeft = matrix[0][0]; + let expectedTopRight = matrix[0][1]; + let expectedBottomLeft = matrix[1][0]; + let expectedBottomRight = matrix[1][1]; + + let topLeft = toUInt32(ctx.getImageData(0, 0, 1, 1), isYuv); + let topRight = toUInt32(ctx.getImageData(width - 1, 0, 1, 1), isYuv); + let bottomLeft = toUInt32(ctx.getImageData(0, height - 1, 1, 1), isYuv); + let bottomRight = + toUInt32(ctx.getImageData(width - 1, height - 1, 1, 1), isYuv); + + assert_equals(getColorName(topLeft), getColorName(expectedTopLeft), + 'top left corner'); + assert_equals(getColorName(topRight), getColorName(expectedTopRight), + 'top right corner'); + assert_equals(getColorName(bottomLeft), getColorName(expectedBottomLeft), + 'bottom left corner'); + assert_equals(getColorName(bottomRight), getColorName(expectedBottomRight), + 'bottom right corner'); +} diff --git a/testing/web-platform/tests/webcodecs/image-decoder.crossOriginIsolated.https.any.js b/testing/web-platform/tests/webcodecs/image-decoder.crossOriginIsolated.https.any.js new file mode 100644 index 0000000000..f10cf7a067 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/image-decoder.crossOriginIsolated.https.any.js @@ -0,0 +1,27 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/image-decoder-utils.js + +function testSharedArrayBuffer(useView) { + const mimeType = 'image/png'; + var decoder = null; + return ImageDecoder.isTypeSupported(mimeType).then(support => { + assert_implements_optional( + support, 'Optional codec ' + mimeType + ' not supported.'); + return fetch('four-colors.png').then(response => { + return response.arrayBuffer().then(buffer => { + let data = new SharedArrayBuffer(buffer.byteLength); + let view = new Uint8Array(data); + view.set(new Uint8Array(buffer)); + return testFourColorsDecodeBuffer(useView ? view : data, mimeType); + }); + }); + }); +} + +promise_test(t => { + return testSharedArrayBuffer(/*useView=*/ false); +}, 'Test ImageDecoder decoding with a SharedArrayBuffer source'); + +promise_test(t => { + return testSharedArrayBuffer(/*useView=*/ true); +}, 'Test ImageDecoder decoding with a Uint8Array(SharedArrayBuffer) source'); diff --git a/testing/web-platform/tests/webcodecs/image-decoder.crossOriginIsolated.https.any.js.headers b/testing/web-platform/tests/webcodecs/image-decoder.crossOriginIsolated.https.any.js.headers new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/image-decoder.crossOriginIsolated.https.any.js.headers @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/testing/web-platform/tests/webcodecs/image-decoder.https.any.js b/testing/web-platform/tests/webcodecs/image-decoder.https.any.js new file mode 100644 index 0000000000..78eea763aa --- /dev/null +++ b/testing/web-platform/tests/webcodecs/image-decoder.https.any.js @@ -0,0 +1,502 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/image-decoder-utils.js + +function testFourColorsDecode(filename, mimeType, options = {}) { + var decoder = null; + return ImageDecoder.isTypeSupported(mimeType).then(support => { + assert_implements_optional( + support, 'Optional codec ' + mimeType + ' not supported.'); + return fetch(filename).then(response => { + return testFourColorsDecodeBuffer(response.body, mimeType, options); + }); + }); +} + +// Note: Requiring all data to do YUV decoding is a Chromium limitation, other +// implementations may support YUV decode with partial ReadableStream data. +function testFourColorsYuvDecode(filename, mimeType, options = {}) { + var decoder = null; + return ImageDecoder.isTypeSupported(mimeType).then(support => { + assert_implements_optional( + support, 'Optional codec ' + mimeType + ' not supported.'); + return fetch(filename).then(response => { + return response.arrayBuffer().then(buffer => { + return testFourColorsDecodeBuffer(buffer, mimeType, options); + }); + }); + }); +} + +promise_test(t => { + return testFourColorsDecode('four-colors.jpg', 'image/jpeg'); +}, 'Test JPEG image decoding.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(1); +}, 'Test JPEG w/ EXIF orientation top-left.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(2); +}, 'Test JPEG w/ EXIF orientation top-right.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(3); +}, 'Test JPEG w/ EXIF orientation bottom-right.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(4); +}, 'Test JPEG w/ EXIF orientation bottom-left.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(5); +}, 'Test JPEG w/ EXIF orientation left-top.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(6); +}, 'Test JPEG w/ EXIF orientation right-top.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(7); +}, 'Test JPEG w/ EXIF orientation right-bottom.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(8); +}, 'Test JPEG w/ EXIF orientation left-bottom.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(1, null, /*useYuv=*/ true); +}, 'Test 4:2:0 JPEG w/ EXIF orientation top-left.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(2, null, /*useYuv=*/ true); +}, 'Test 4:2:0 JPEG w/ EXIF orientation top-right.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(3, null, /*useYuv=*/ true); +}, 'Test 4:2:0 JPEG w/ EXIF orientation bottom-right.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(4, null, /*useYuv=*/ true); +}, 'Test 4:2:0 JPEG w/ EXIF orientation bottom-left.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(5, null, /*useYuv=*/ true); +}, 'Test 4:2:0 JPEG w/ EXIF orientation left-top.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(6, null, /*useYuv=*/ true); +}, 'Test 4:2:0 JPEG w/ EXIF orientation right-top.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(7, null, /*useYuv=*/ true); +}, 'Test 4:2:0 JPEG w/ EXIF orientation right-bottom.'); + +promise_test(t => { + return testFourColorDecodeWithExifOrientation(8, null, /*useYuv=*/ true); +}, 'Test 4:2:0 JPEG w/ EXIF orientation left-bottom.'); + + +promise_test(t => { + return testFourColorsDecode('four-colors.png', 'image/png'); +}, 'Test PNG image decoding.'); + +promise_test(t => { + return testFourColorsDecode('four-colors.avif', 'image/avif'); +}, 'Test AVIF image decoding.'); + +promise_test(t => { + return testFourColorsDecode( + 'four-colors-full-range-bt2020-pq-444-10bpc.avif', 'image/avif', + { tolerance: 3 }); +}, 'Test high bit depth HDR AVIF image decoding.'); + +promise_test(t => { + return testFourColorsDecode( + 'four-colors-flip.avif', 'image/avif', {preferAnimation: false}); +}, 'Test multi-track AVIF image decoding w/ preferAnimation=false.'); + +promise_test(t => { + return testFourColorsDecode( + 'four-colors-flip.avif', 'image/avif', {preferAnimation: true}); +}, 'Test multi-track AVIF image decoding w/ preferAnimation=true.'); + +promise_test(t => { + return testFourColorsDecode('four-colors.webp', 'image/webp'); +}, 'Test WEBP image decoding.'); + +promise_test(t => { + return testFourColorsDecode('four-colors.gif', 'image/gif'); +}, 'Test GIF image decoding.'); + +promise_test(t => { + return testFourColorsYuvDecode( + 'four-colors-limited-range-420-8bpc.jpg', 'image/jpeg', + {yuvFormat: 'I420', tolerance: 3}); +}, 'Test JPEG image YUV 4:2:0 decoding.'); + +promise_test(t => { + return testFourColorsYuvDecode( + 'four-colors-limited-range-420-8bpc.avif', 'image/avif', + {yuvFormat: 'I420', tolerance: 3}); +}, 'Test AVIF image YUV 4:2:0 decoding.'); + +promise_test(t => { + return testFourColorsYuvDecode( + 'four-colors-limited-range-422-8bpc.avif', 'image/avif', + {yuvFormat: 'I422', tolerance: 3}); +}, 'Test AVIF image YUV 4:2:2 decoding.'); + +promise_test(t => { + return testFourColorsYuvDecode( + 'four-colors-limited-range-444-8bpc.avif', 'image/avif', + {yuvFormat: 'I444', tolerance: 3}); +}, 'Test AVIF image YUV 4:4:4 decoding.'); + +promise_test(t => { + return testFourColorsYuvDecode( + 'four-colors-limited-range-420-8bpc.webp', 'image/webp', + {yuvFormat: 'I420', tolerance: 3}); +}, 'Test WEBP image YUV 4:2:0 decoding.'); + +promise_test(t => { + return fetch('four-colors.png').then(response => { + let decoder = new ImageDecoder({data: response.body, type: 'junk/type'}); + return promise_rejects_dom(t, 'NotSupportedError', decoder.decode()); + }); +}, 'Test invalid mime type rejects decode() requests'); + +promise_test(t => { + return fetch('four-colors.png').then(response => { + let decoder = new ImageDecoder({data: response.body, type: 'junk/type'}); + return promise_rejects_dom(t, 'NotSupportedError', decoder.tracks.ready); + }); +}, 'Test invalid mime type rejects decodeMetadata() requests'); + +promise_test(t => { + return ImageDecoder.isTypeSupported('image/png').then(support => { + assert_implements_optional( + support, 'Optional codec image/png not supported.'); + return fetch('four-colors.png') + .then(response => { + return response.arrayBuffer(); + }) + .then(buffer => { + let decoder = new ImageDecoder({data: buffer, type: 'image/png'}); + return promise_rejects_js( + t, RangeError, decoder.decode({frameIndex: 1})); + }); + }); +}, 'Test out of range index returns RangeError'); + +promise_test(t => { + var decoder; + var p1; + return ImageDecoder.isTypeSupported('image/png').then(support => { + assert_implements_optional( + support, 'Optional codec image/png not supported.'); + return fetch('four-colors.png') + .then(response => { + return response.arrayBuffer(); + }) + .then(buffer => { + decoder = + new ImageDecoder({data: buffer.slice(0, 100), type: 'image/png'}); + return decoder.tracks.ready; + }) + .then(_ => { + // Queue two decodes to ensure index verification and decoding are + // properly ordered. + p1 = decoder.decode({frameIndex: 0}); + return promise_rejects_js( + t, RangeError, decoder.decode({frameIndex: 1})); + }) + .then(_ => { + return promise_rejects_js(t, RangeError, p1); + }) + }); +}, 'Test partial decoding without a frame results in an error'); + +promise_test(t => { + var decoder; + var p1; + return ImageDecoder.isTypeSupported('image/png').then(support => { + assert_implements_optional( + support, 'Optional codec image/png not supported.'); + return fetch('four-colors.png') + .then(response => { + return response.arrayBuffer(); + }) + .then(buffer => { + decoder = + new ImageDecoder({data: buffer.slice(0, 100), type: 'image/png'}); + return decoder.completed; + }) + }); +}, 'Test completed property on fully buffered decode'); + +promise_test(t => { + var decoder = null; + + return ImageDecoder.isTypeSupported('image/png').then(support => { + assert_implements_optional( + support, 'Optional codec image/png not supported.'); + return fetch('four-colors.png') + .then(response => { + decoder = new ImageDecoder({data: response.body, type: 'image/png'}); + return decoder.tracks.ready; + }) + .then(_ => { + decoder.tracks.selectedTrack.selected = false; + assert_equals(decoder.tracks.selectedIndex, -1); + assert_equals(decoder.tracks.selectedTrack, null); + return decoder.tracks.ready; + }) + .then(_ => { + return promise_rejects_dom(t, 'InvalidStateError', decoder.decode()); + }) + .then(_ => { + decoder.tracks[0].selected = true; + assert_equals(decoder.tracks.selectedIndex, 0); + assert_not_equals(decoder.tracks.selected, null); + return decoder.decode(); + }) + .then(result => { + assert_equals(result.image.displayWidth, 320); + assert_equals(result.image.displayHeight, 240); + }); + }); +}, 'Test decode, decodeMetadata after no track selected.'); + +promise_test(t => { + var decoder = null; + + return ImageDecoder.isTypeSupported('image/avif').then(support => { + assert_implements_optional( + support, 'Optional codec image/avif not supported.'); + return fetch('four-colors-flip.avif') + .then(response => { + decoder = new ImageDecoder({ + data: response.body, + type: 'image/avif', + preferAnimation: false + }); + return decoder.tracks.ready; + }) + .then(_ => { + assert_equals(decoder.tracks.length, 2); + assert_false(decoder.tracks[decoder.tracks.selectedIndex].animated) + assert_false(decoder.tracks.selectedTrack.animated); + assert_equals(decoder.tracks.selectedTrack.frameCount, 1); + assert_equals(decoder.tracks.selectedTrack.repetitionCount, 0); + return decoder.decode(); + }) + .then(result => { + assert_equals(result.image.displayWidth, 320); + assert_equals(result.image.displayHeight, 240); + assert_equals(result.image.timestamp, 0); + + // Swap to the the other track. + let newIndex = (decoder.tracks.selectedIndex + 1) % 2; + decoder.tracks[newIndex].selected = true; + return decoder.decode() + }) + .then(result => { + assert_equals(result.image.displayWidth, 320); + assert_equals(result.image.displayHeight, 240); + assert_equals(result.image.timestamp, 0); + assert_equals(result.image.duration, 10000); + + assert_equals(decoder.tracks.length, 2); + assert_true(decoder.tracks[decoder.tracks.selectedIndex].animated) + assert_true(decoder.tracks.selectedTrack.animated); + assert_equals(decoder.tracks.selectedTrack.frameCount, 7); + assert_equals(decoder.tracks.selectedTrack.repetitionCount, Infinity); + return decoder.decode({frameIndex: 1}); + }) + .then(result => { + assert_equals(result.image.timestamp, 10000); + assert_equals(result.image.duration, 10000); + }); + }); +}, 'Test track selection in multi track image.'); + +class InfiniteGifSource { + async load(repetitionCount) { + let response = await fetch('four-colors-flip.gif'); + let buffer = await response.arrayBuffer(); + + // Strip GIF trailer (0x3B) so we can continue to append frames. + this.baseImage = new Uint8Array(buffer.slice(0, buffer.byteLength - 1)); + this.baseImage[0x23] = repetitionCount; + this.counter = 0; + } + + start(controller) { + this.controller = controller; + this.controller.enqueue(this.baseImage); + } + + close() { + this.controller.enqueue(new Uint8Array([0x3B])); + this.controller.close(); + } + + addFrame() { + const FRAME1_START = 0x26; + const FRAME2_START = 0x553; + + if (this.counter++ % 2 == 0) + this.controller.enqueue(this.baseImage.slice(FRAME1_START, FRAME2_START)); + else + this.controller.enqueue(this.baseImage.slice(FRAME2_START)); + } +} + +promise_test(async t => { + let support = await ImageDecoder.isTypeSupported('image/gif'); + assert_implements_optional( + support, 'Optional codec image/gif not supported.'); + + let source = new InfiniteGifSource(); + await source.load(5); + + let stream = new ReadableStream(source, {type: 'bytes'}); + let decoder = new ImageDecoder({data: stream, type: 'image/gif'}); + return decoder.tracks.ready + .then(_ => { + assert_equals(decoder.tracks.selectedTrack.frameCount, 2); + assert_equals(decoder.tracks.selectedTrack.repetitionCount, 5); + + source.addFrame(); + return decoder.decode({frameIndex: 2}); + }) + .then(result => { + assert_equals(decoder.tracks.selectedTrack.frameCount, 3); + assert_equals(result.image.displayWidth, 320); + assert_equals(result.image.displayHeight, 240); + + // Note: The stream has an alternating duration of 30ms, 40ms per frame. + assert_equals(result.image.timestamp, 70000, "timestamp frame 2"); + assert_equals(result.image.duration, 30000, "duration frame 2"); + source.addFrame(); + return decoder.decode({frameIndex: 3}); + }) + .then(result => { + assert_equals(decoder.tracks.selectedTrack.frameCount, 4); + assert_equals(result.image.displayWidth, 320); + assert_equals(result.image.displayHeight, 240); + assert_equals(result.image.timestamp, 100000, "timestamp frame 3"); + assert_equals(result.image.duration, 40000, "duration frame 3"); + + // Decode frame not yet available then reset before it comes in. + let p = decoder.decode({frameIndex: 5}); + decoder.reset(); + return promise_rejects_dom(t, 'AbortError', p); + }) + .then(_ => { + // Ensure we can still decode earlier frames. + assert_equals(decoder.tracks.selectedTrack.frameCount, 4); + return decoder.decode({frameIndex: 3}); + }) + .then(result => { + assert_equals(decoder.tracks.selectedTrack.frameCount, 4); + assert_equals(result.image.displayWidth, 320); + assert_equals(result.image.displayHeight, 240); + assert_equals(result.image.timestamp, 100000, "timestamp frame 3"); + assert_equals(result.image.duration, 40000, "duration frame 3"); + + // Decode frame not yet available then close before it comes in. + let p = decoder.decode({frameIndex: 5}); + let tracks = decoder.tracks; + let track = decoder.tracks.selectedTrack; + decoder.close(); + + assert_equals(decoder.type, ''); + assert_equals(decoder.tracks.length, 0); + assert_equals(tracks.length, 0); + track.selected = true; // Should do nothing. + + // Previous decode should be aborted. + return promise_rejects_dom(t, 'AbortError', p); + }) + .then(_ => { + // Ensure feeding the source after closing doesn't crash. + assert_throws_js(TypeError, () => { + source.addFrame(); + }); + }); +}, 'Test ReadableStream of gif'); + +promise_test(async t => { + let support = await ImageDecoder.isTypeSupported('image/gif'); + assert_implements_optional( + support, 'Optional codec image/gif not supported.'); + + let source = new InfiniteGifSource(); + await source.load(5); + + let stream = new ReadableStream(source, {type: 'bytes'}); + let decoder = new ImageDecoder({data: stream, type: 'image/gif'}); + return decoder.tracks.ready.then(_ => { + assert_equals(decoder.tracks.selectedTrack.frameCount, 2); + assert_equals(decoder.tracks.selectedTrack.repetitionCount, 5); + + decoder.decode({frameIndex: 2}).then(t.unreached_func()); + decoder.decode({frameIndex: 1}).then(t.unreached_func()); + return decoder.tracks.ready; + }); +}, 'Test that decode requests are serialized.'); + +promise_test(async t => { + let support = await ImageDecoder.isTypeSupported('image/gif'); + assert_implements_optional( + support, 'Optional codec image/gif not supported.'); + + let source = new InfiniteGifSource(); + await source.load(5); + + let stream = new ReadableStream(source, {type: 'bytes'}); + let decoder = new ImageDecoder({data: stream, type: 'image/gif'}); + return decoder.tracks.ready.then(_ => { + assert_equals(decoder.tracks.selectedTrack.frameCount, 2); + assert_equals(decoder.tracks.selectedTrack.repetitionCount, 5); + + // Decode frame not yet available then change tracks before it comes in. + let p = decoder.decode({frameIndex: 5}); + decoder.tracks.selectedTrack.selected = false; + return promise_rejects_dom(t, 'AbortError', p); + }); +}, 'Test ReadableStream aborts promises on track change'); + +promise_test(async t => { + let support = await ImageDecoder.isTypeSupported('image/gif'); + assert_implements_optional( + support, 'Optional codec image/gif not supported.'); + + let source = new InfiniteGifSource(); + await source.load(5); + + let stream = new ReadableStream(source, {type: 'bytes'}); + let decoder = new ImageDecoder({data: stream, type: 'image/gif'}); + return decoder.tracks.ready.then(_ => { + let p = decoder.completed; + decoder.close(); + return promise_rejects_dom(t, 'AbortError', p); + }); +}, 'Test ReadableStream aborts completed on close'); + +promise_test(async t => { + let support = await ImageDecoder.isTypeSupported('image/gif'); + assert_implements_optional( + support, 'Optional codec image/gif not supported.'); + + let source = new InfiniteGifSource(); + await source.load(5); + + let stream = new ReadableStream(source, {type: 'bytes'}); + let decoder = new ImageDecoder({data: stream, type: 'image/gif'}); + return decoder.tracks.ready.then(_ => { + source.close(); + return decoder.completed; + }); +}, 'Test ReadableStream resolves completed'); diff --git a/testing/web-platform/tests/webcodecs/pattern.png b/testing/web-platform/tests/webcodecs/pattern.png new file mode 100644 index 0000000000..85676f29ff Binary files /dev/null and b/testing/web-platform/tests/webcodecs/pattern.png differ diff --git a/testing/web-platform/tests/webcodecs/per-frame-qp-encoding.https.any.js b/testing/web-platform/tests/webcodecs/per-frame-qp-encoding.https.any.js new file mode 100644 index 0000000000..8ec96c3f70 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/per-frame-qp-encoding.https.any.js @@ -0,0 +1,138 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/video-encoder-utils.js +// META: variant=?av1 +// META: variant=?vp9_p0 +// META: variant=?vp9_p2 + +function get_config() { + const config = { + '?av1': {codec: 'av01.0.04M.08'}, + '?vp8': {codec: 'vp8'}, + '?vp9_p0': {codec: 'vp09.00.10.08'}, + '?vp9_p2': {codec: 'vp09.02.10.10'}, + '?h264': {codec: 'avc1.42001E', avc: {format: 'annexb'}} + }[location.search]; + config.width = 320; + config.height = 200; + config.bitrate = 1000000; + config.bitrateMode = 'quantizer'; + config.framerate = 30; + return config; +} + +function get_qp_range() { + switch (location.search) { + case '?av1': + return {min: 1, max: 63}; + case '?vp9_p0': + return {min: 1, max: 63}; + case '?vp9_p2': + return {min: 1, max: 63}; + case '?h264': + return {min: 1, max: 51}; + } + return null; +} + +function set_qp(options, value) { + switch (location.search) { + case '?av1': + options.av1 = {quantizer: value}; + return; + case '?vp9_p0': + options.vp9 = {quantizer: value}; + return; + case '?vp9_p2': + options.vp9 = {quantizer: value}; + return; + case '?h264': + options.avc = {quantizer: value}; + return; + } +} + +async function per_frame_qp_test(t, encoder_config, qp_range, validate_result) { + const w = encoder_config.width; + const h = encoder_config.height; + await checkEncoderSupport(t, encoder_config); + + const frames_to_encode = 12; + let frames_decoded = 0; + let frames_encoded = 0; + let chunks = []; + let corrupted_frames = []; + + const encoder_init = { + output(chunk, metadata) { + frames_encoded++; + chunks.push(chunk); + }, + error(e) { + assert_unreached(e.message); + } + }; + + let encoder = new VideoEncoder(encoder_init); + encoder.configure(encoder_config); + + let qp = qp_range.min; + for (let i = 0; i < frames_to_encode; i++) { + let frame = createDottedFrame(w, h, i); + let encode_options = {keyFrame: false}; + set_qp(encode_options, qp); + encoder.encode(frame, encode_options); + frame.close(); + qp += 3; + if (qp > qp_range.max) { + qp = qp_range.min + } + } + await encoder.flush(); + + let decoder = new VideoDecoder({ + output(frame) { + frames_decoded++; + // Check that we have intended number of dots and no more. + // Completely black frame shouldn't pass the test. + if (validate_result && !validateBlackDots(frame, frame.timestamp) || + validateBlackDots(frame, frame.timestamp + 1)) { + corrupted_frames.push(frame.timestamp) + } + frame.close(); + }, + error(e) { + assert_unreached(e.message); + } + }); + + let decoder_config = { + codec: encoder_config.codec, + codedWidth: w, + codedHeight: h, + }; + decoder.configure(decoder_config); + + for (let chunk of chunks) { + decoder.decode(chunk); + } + await decoder.flush(); + + encoder.close(); + decoder.close(); + assert_equals(frames_encoded, frames_to_encode); + assert_equals(chunks.length, frames_to_encode); + assert_equals(frames_decoded, frames_to_encode); + assert_equals( + corrupted_frames.length, 0, `corrupted_frames: ${corrupted_frames}`); +} + +promise_test(async t => { + let config = get_config(); + let range = get_qp_range(); + return per_frame_qp_test(t, config, range, false); +}, 'Frame QP encoding, full range'); + +promise_test(async t => { + let config = get_config(); + return per_frame_qp_test(t, config, {min: 1, max: 20}, true); +}, 'Frame QP encoding, good range with validation'); diff --git a/testing/web-platform/tests/webcodecs/reconfiguring-encoder.https.any.js b/testing/web-platform/tests/webcodecs/reconfiguring-encoder.https.any.js new file mode 100644 index 0000000000..cb0f55efab --- /dev/null +++ b/testing/web-platform/tests/webcodecs/reconfiguring-encoder.https.any.js @@ -0,0 +1,121 @@ +// META: timeout=long +// META: global=window,dedicatedworker +// META: script=/webcodecs/video-encoder-utils.js +// META: variant=?av1 +// META: variant=?vp8 +// META: variant=?vp9_p0 +// META: variant=?vp9_p2 +// META: variant=?h264_avc +// META: variant=?h264_annexb + +var ENCODER_CONFIG = null; +promise_setup(async () => { + const config = { + '?av1': {codec: 'av01.0.04M.08'}, + '?vp8': {codec: 'vp8'}, + '?vp9_p0': {codec: 'vp09.00.10.08'}, + '?vp9_p2': {codec: 'vp09.02.10.10'}, + '?h264_avc': {codec: 'avc1.42001F', avc: {format: 'avc'}}, + '?h264_annexb': {codec: 'avc1.42001F', avc: {format: 'annexb'}} + }[location.search]; + config.hardwareAcceleration = 'prefer-software'; + config.bitrateMode = "constant"; + config.framerate = 30; + ENCODER_CONFIG = config; +}); + +promise_test(async t => { + let original_w = 800; + let original_h = 600; + let original_bitrate = 3_000_000; + + let new_w = 640; + let new_h = 480; + let new_bitrate = 2_000_000; + + let next_ts = 0 + let reconf_ts = 0; + let frames_to_encode = 16; + let before_reconf_frames = 0; + let after_reconf_frames = 0; + + let process_video_chunk = function (chunk, metadata) { + let config = metadata.decoderConfig; + var data = new Uint8Array(chunk.data); + assert_greater_than_equal(data.length, 0); + let after_reconf = (reconf_ts != 0) && (chunk.timestamp >= reconf_ts); + if (after_reconf) { + after_reconf_frames++; + if (config) { + assert_equals(config.codedWidth, new_w); + assert_equals(config.codedHeight, new_h); + } + } else { + before_reconf_frames++; + if (config) { + assert_equals(config.codedWidth, original_w); + assert_equals(config.codedHeight, original_h); + } + } + }; + + const init = { + output: (chunk, md) => { + try { + process_video_chunk(chunk, md); + } catch (e) { + assert_unreached(e.message); + } + }, + error: (e) => { + assert_unreached(e.message); + }, + }; + const params = { + ...ENCODER_CONFIG, + width: original_w, + height: original_h, + bitrate: original_bitrate, + }; + await checkEncoderSupport(t, params); + + let encoder = new VideoEncoder(init); + encoder.configure(params); + + // Remove this flush after crbug.com/1275789 is fixed + await encoder.flush(); + + // Encode |frames_to_encode| frames with original settings + for (let i = 0; i < frames_to_encode; i++) { + var frame = createFrame(original_w, original_h, next_ts++); + encoder.encode(frame, {}); + frame.close(); + } + + params.width = new_w; + params.height = new_h; + params.bitrate = new_bitrate; + + // Reconfigure encoder and run |frames_to_encode| frames with new settings + encoder.configure(params); + reconf_ts = next_ts; + + for (let i = 0; i < frames_to_encode; i++) { + var frame = createFrame(new_w, new_h, next_ts++); + encoder.encode(frame, {}); + frame.close(); + } + + await encoder.flush(); + + // Configure back to original config + params.width = original_w; + params.height = original_h; + params.bitrate = original_bitrate; + encoder.configure(params); + await encoder.flush(); + + encoder.close(); + assert_equals(before_reconf_frames, frames_to_encode); + assert_equals(after_reconf_frames, frames_to_encode); +}, "Reconfiguring encoder"); diff --git a/testing/web-platform/tests/webcodecs/serialization.crossAgentCluster.serviceworker.js b/testing/web-platform/tests/webcodecs/serialization.crossAgentCluster.serviceworker.js new file mode 100644 index 0000000000..bb3ec0df5b --- /dev/null +++ b/testing/web-platform/tests/webcodecs/serialization.crossAgentCluster.serviceworker.js @@ -0,0 +1,61 @@ +let videoFrameMap = new Map(); +let encodedVideoChunkMap = new Map(); + +self.onmessage = (e) => { + if (e.data == 'create-VideoFrame') { + let frameOrError = null; + try { + frameOrError = new VideoFrame( + new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + ]), { + timestamp: 0, + codedWidth: 2, + codedHeight: 2, + format: 'RGBA', + }); + } catch (error) { + frameOrError = error + } + e.source.postMessage(frameOrError); + return; + } + + if (e.data == 'create-EncodedVideoChunk') { + let chunkOrError = null; + try { + chunkOrError = new EncodedVideoChunk({ + type: 'key', + timestamp: 0, + duration: 1, + data: new Uint8Array([2, 3, 4, 5]) + }); + } catch (error) { + chunkOrError = error + } + e.source.postMessage(chunkOrError); + return; + } + + if (e.data.hasOwnProperty('videoFrameId')) { + e.source.postMessage( + videoFrameMap.get(e.data.videoFrameId) ? 'RECEIVED' : 'NOT_RECEIVED'); + return; + } + + if (e.data.hasOwnProperty('encodedVideoChunkId')) { + e.source.postMessage( + encodedVideoChunkMap.get(e.data.encodedVideoChunkId) ? 'RECEIVED' : 'NOT_RECEIVED'); + return; + } + + if (e.data.toString() == '[object VideoFrame]') { + videoFrameMap.set(e.data.timestamp, e.data); + return; + } + + if (e.data.toString() == '[object EncodedVideoChunk]') { + encodedVideoChunkMap.set(e.data.timestamp, e.data); + } +}; diff --git a/testing/web-platform/tests/webcodecs/sfx-aac.mp4 b/testing/web-platform/tests/webcodecs/sfx-aac.mp4 new file mode 100644 index 0000000000..c7b3417d9c Binary files /dev/null and b/testing/web-platform/tests/webcodecs/sfx-aac.mp4 differ diff --git a/testing/web-platform/tests/webcodecs/sfx-alaw.wav b/testing/web-platform/tests/webcodecs/sfx-alaw.wav new file mode 100644 index 0000000000..da9a22759c Binary files /dev/null and b/testing/web-platform/tests/webcodecs/sfx-alaw.wav differ diff --git a/testing/web-platform/tests/webcodecs/sfx-mulaw.wav b/testing/web-platform/tests/webcodecs/sfx-mulaw.wav new file mode 100644 index 0000000000..ba9d6bdf1b Binary files /dev/null and b/testing/web-platform/tests/webcodecs/sfx-mulaw.wav differ diff --git a/testing/web-platform/tests/webcodecs/sfx-opus.ogg b/testing/web-platform/tests/webcodecs/sfx-opus.ogg new file mode 100644 index 0000000000..01a9b862ce Binary files /dev/null and b/testing/web-platform/tests/webcodecs/sfx-opus.ogg differ diff --git a/testing/web-platform/tests/webcodecs/sfx.adts b/testing/web-platform/tests/webcodecs/sfx.adts new file mode 100644 index 0000000000..80f9c8c91f Binary files /dev/null and b/testing/web-platform/tests/webcodecs/sfx.adts differ diff --git a/testing/web-platform/tests/webcodecs/sfx.mp3 b/testing/web-platform/tests/webcodecs/sfx.mp3 new file mode 100644 index 0000000000..d260017446 Binary files /dev/null and b/testing/web-platform/tests/webcodecs/sfx.mp3 differ diff --git a/testing/web-platform/tests/webcodecs/temporal-svc-encoding.https.any.js b/testing/web-platform/tests/webcodecs/temporal-svc-encoding.https.any.js new file mode 100644 index 0000000000..7cf7225e5d --- /dev/null +++ b/testing/web-platform/tests/webcodecs/temporal-svc-encoding.https.any.js @@ -0,0 +1,105 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/video-encoder-utils.js +// META: variant=?av1 +// META: variant=?vp8 +// META: variant=?vp9 +// META: variant=?h264 + +var ENCODER_CONFIG = null; +promise_setup(async () => { + const config = { + '?av1': {codec: 'av01.0.04M.08'}, + '?vp8': {codec: 'vp8'}, + '?vp9': {codec: 'vp09.00.10.08'}, + '?h264': {codec: 'avc1.42001E', avc: {format: 'annexb'}} + }[location.search]; + config.hardwareAcceleration = 'prefer-software'; + config.width = 320; + config.height = 200; + config.bitrate = 1000000; + config.bitrateMode = "constant"; + config.framerate = 30; + ENCODER_CONFIG = config; +}); + +async function svc_test(t, layers, base_layer_decimator) { + let encoder_config = { ...ENCODER_CONFIG }; + encoder_config.scalabilityMode = "L1T" + layers; + const w = encoder_config.width; + const h = encoder_config.height; + await checkEncoderSupport(t, encoder_config); + + let frames_to_encode = 24; + let frames_decoded = 0; + let frames_encoded = 0; + let chunks = []; + let corrupted_frames = []; + + const encoder_init = { + output(chunk, metadata) { + frames_encoded++; + + // Filter out all frames, but base layer. + assert_own_property(metadata, "svc"); + assert_own_property(metadata.svc, "temporalLayerId"); + assert_less_than(metadata.svc.temporalLayerId, layers); + if (metadata.svc.temporalLayerId == 0) + chunks.push(chunk); + }, + error(e) { + assert_unreached(e.message); + } + }; + + let encoder = new VideoEncoder(encoder_init); + encoder.configure(encoder_config); + + for (let i = 0; i < frames_to_encode; i++) { + let frame = createDottedFrame(w, h, i); + encoder.encode(frame, { keyFrame: false }); + frame.close(); + } + await encoder.flush(); + + let decoder = new VideoDecoder({ + output(frame) { + frames_decoded++; + // Check that we have intended number of dots and no more. + // Completely black frame shouldn't pass the test. + if(!validateBlackDots(frame, frame.timestamp) || + validateBlackDots(frame, frame.timestamp + 1)) { + corrupted_frames.push(frame.timestamp) + } + frame.close(); + }, + error(e) { + assert_unreached(e.message); + } + }); + + let decoder_config = { + codec: encoder_config.codec, + hardwareAcceleration: encoder_config.hardwareAcceleration, + codedWidth: w, + codedHeight: h, + }; + decoder.configure(decoder_config); + + for (let chunk of chunks) { + decoder.decode(chunk); + } + await decoder.flush(); + + encoder.close(); + decoder.close(); + assert_equals(frames_encoded, frames_to_encode); + + let base_layer_frames = frames_to_encode / base_layer_decimator; + assert_equals(chunks.length, base_layer_frames); + assert_equals(frames_decoded, base_layer_frames); + assert_equals(corrupted_frames.length, 0, + `corrupted_frames: ${corrupted_frames}`); +} + +promise_test(async t => { return svc_test(t, 2, 2) }, "SVC L1T2"); +promise_test(async t => { return svc_test(t, 3, 4) }, "SVC L1T3"); diff --git a/testing/web-platform/tests/webcodecs/transfering.https.any.js b/testing/web-platform/tests/webcodecs/transfering.https.any.js new file mode 100644 index 0000000000..44060b968a --- /dev/null +++ b/testing/web-platform/tests/webcodecs/transfering.https.any.js @@ -0,0 +1,281 @@ +// META: global=window,dedicatedworker + +promise_test(async t => { + let fmt = 'RGBA'; + const rgb_plane = [ + 0xBA, 0xDF, 0x00, 0xD0, 0xBA, 0xDF, 0x01, 0xD0, 0xBA, 0xDF, 0x02, 0xD0, + 0xBA, 0xDF, 0x03, 0xD0 + ]; + let data = new Uint8Array(rgb_plane); + let unused_buffer = new ArrayBuffer(123); + let init = { + format: fmt, + timestamp: 1234, + codedWidth: 2, + codedHeight: 2, + visibleRect: {x: 0, y: 0, width: 2, height: 2}, + transfer: [data.buffer, unused_buffer] + }; + assert_equals(data.length, 16, 'data.length'); + assert_equals(unused_buffer.byteLength, 123, 'unused_buffer.byteLength'); + + let frame = new VideoFrame(data, init); + assert_equals(frame.format, fmt, 'format'); + assert_equals(data.length, 0, 'data.length after detach'); + assert_equals(unused_buffer.byteLength, 0, 'unused_buffer after detach'); + + const options = { + rect: {x: 0, y: 0, width: init.codedWidth, height: init.codedHeight} + }; + let size = frame.allocationSize(options); + let output_data = new Uint8Array(size); + let layout = await frame.copyTo(output_data, options); + let expected_data = new Uint8Array(rgb_plane); + assert_equals(expected_data.length, size, 'expected_data size'); + for (let i = 0; i < size; i++) { + assert_equals(expected_data[i], output_data[i], `expected_data[${i}]`); + } + + frame.close(); +}, 'Test transfering ArrayBuffer to VideoFrame'); + + +promise_test(async t => { + const rgb_plane = [ + 0xBA, 0xDF, 0x00, 0xD0, 0xBA, 0xDF, 0x01, 0xD0, 0xBA, 0xDF, 0x02, 0xD0, + 0xBA, 0xDF, 0x03, 0xD0 + ]; + let data = new Uint8Array(rgb_plane); + let detached_buffer = new ArrayBuffer(123); + + // Detach `detached_buffer` + structuredClone({x: detached_buffer}, {transfer: [detached_buffer]}); + + let init = { + format: 'RGBA', + timestamp: 1234, + codedWidth: 2, + codedHeight: 2, + visibleRect: {x: 0, y: 0, width: 2, height: 2}, + transfer: [data.buffer, detached_buffer] + }; + + try { + new VideoFrame(data, init); + } catch (error) { + assert_equals(error.name, 'DataCloneError', 'error.name'); + } + // `data.buffer` didn't get detached + assert_equals(data.length, 16, 'data.length'); +}, 'Test transfering detached buffer to VideoFrame'); + + +promise_test(async t => { + const rgb_plane = [ + 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, + 0xEE, 0xEE, 0xEE, 0xEE + ]; + const padding_size = 6; + let arraybuffer = new ArrayBuffer(padding_size + 16 /* pixels */); + let data = new Uint8Array(arraybuffer, padding_size); + data.set(rgb_plane); + + let init = { + format: 'RGBA', + timestamp: 1234, + codedWidth: 2, + codedHeight: 2, + visibleRect: {x: 0, y: 0, width: 2, height: 2}, + transfer: [arraybuffer] + }; + + let frame = new VideoFrame(data, init); + assert_equals(data.length, 0, 'data.length after detach'); + assert_equals(arraybuffer.byteLength, 0, 'arraybuffer after detach'); + + const options = { + rect: {x: 0, y: 0, width: init.codedWidth, height: init.codedHeight} + }; + let size = frame.allocationSize(options); + let output_data = new Uint8Array(size); + let layout = await frame.copyTo(output_data, options); + for (let i = 0; i < size; i++) { + assert_equals(output_data[i], 0xEE, `output_data[${i}]`); + } +}, 'Test transfering view of an ArrayBuffer to VideoFrame'); + +promise_test(async t => { + const rgb_plane = [ + 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, 0xEE, + 0xEE, 0xEE, 0xEE, 0xEE + ]; + const padding_size = 6; + let arraybuffer = new ArrayBuffer(padding_size + 16 /* pixels */); + let data = new Uint8Array(arraybuffer, padding_size); + data.set(rgb_plane); + + let init = { + format: 'RGBA', + timestamp: 1234, + codedWidth: 2, + codedHeight: 2, + visibleRect: {x: 0, y: 0, width: 2, height: 2}, + transfer: [arraybuffer, arraybuffer] + }; + + try { + new VideoFrame(data, init); + } catch (error) { + assert_equals(error.name, 'DataCloneError', 'error.name'); + } + // `data.buffer` didn't get detached + assert_equals(data.length, 16, 'data.length'); +}, 'Test transfering same array buffer twice'); + +promise_test(async t => { + const bytes = [ 0xBA, 0xDF, 0x00, 0xD0, 0xBA, 0xDF, 0x01, 0xD0, 0xBA, 0xDF ]; + let data = new Uint8Array(bytes); + let unused_buffer = new ArrayBuffer(123); + let init = { + type: 'key', + timestamp: 0, + data: data, + transfer: [data.buffer, unused_buffer] + }; + + assert_equals(data.length, 10, 'data.length'); + assert_equals(unused_buffer.byteLength, 123, 'unused_buffer.byteLength'); + + let chunk = new EncodedAudioChunk(init); + assert_equals(data.length, 0, 'data.length after detach'); + assert_equals(unused_buffer.byteLength, 0, 'unused_buffer after detach'); + + let output_data = new Uint8Array(chunk.byteLength); + chunk.copyTo(output_data); + let expected_data = new Uint8Array(bytes); + assert_equals(expected_data.length, chunk.byteLength, 'expected_data size'); + for (let i = 0; i < chunk.byteLength; i++) { + assert_equals(expected_data[i], output_data[i], `expected_data[${i}]`); + } +}, 'Test transfering ArrayBuffer to EncodedAudioChunk'); + +promise_test(async t => { + const bytes = [ 0xBA, 0xDF, 0x00, 0xD0, 0xBA, 0xDF, 0x01, 0xD0, 0xBA, 0xDF ]; + let data = new Uint8Array(bytes); + let unused_buffer = new ArrayBuffer(123); + let init = { + type: 'key', + timestamp: 0, + data: data, + transfer: [data.buffer, unused_buffer] + }; + + assert_equals(data.length, 10, 'data.length'); + assert_equals(unused_buffer.byteLength, 123, 'unused_buffer.byteLength'); + + let chunk = new EncodedVideoChunk(init); + assert_equals(data.length, 0, 'data.length after detach'); + assert_equals(unused_buffer.byteLength, 0, 'unused_buffer after detach'); + + let output_data = new Uint8Array(chunk.byteLength); + chunk.copyTo(output_data); + let expected_data = new Uint8Array(bytes); + assert_equals(expected_data.length, chunk.byteLength, 'expected_data size'); + for (let i = 0; i < chunk.byteLength; i++) { + assert_equals(expected_data[i], output_data[i], `expected_data[${i}]`); + } +}, 'Test transfering ArrayBuffer to EncodedVideoChunk'); + +promise_test(async t => { + const bytes = [0xBA, 0xDF, 0x00, 0xD0, 0xBA, 0xDF, 0x01, 0xD0, 0xBA, 0xDF]; + let data = new Uint8Array(bytes); + let unused_buffer = new ArrayBuffer(123); + let init = { + type: 'key', + timestamp: 0, + numberOfFrames: data.length, + numberOfChannels: 1, + sampleRate: 10000, + format: 'u8', + data: data, + transfer: [data.buffer, unused_buffer] + }; + + assert_equals(data.length, 10, 'data.length'); + assert_equals(unused_buffer.byteLength, 123, 'unused_buffer.byteLength'); + + let audio_data = new AudioData(init); + assert_equals(data.length, 0, 'data.length after detach'); + assert_equals(unused_buffer.byteLength, 0, 'unused_buffer after detach'); + + let readback_data = new Uint8Array(bytes.length); + audio_data.copyTo(readback_data, {planeIndex: 0, format: 'u8'}); + let expected_data = new Uint8Array(bytes); + for (let i = 0; i < expected_data.length; i++) { + assert_equals(expected_data[i], readback_data[i], `expected_data[${i}]`); + } +}, 'Test transfering ArrayBuffer to AudioData'); + +promise_test(async t => { + let sample_rate = 48000; + let total_duration_s = 1; + let data_count = 10; + let chunks = []; + + let encoder_init = { + error: t.unreached_func('Encoder error'), + output: (chunk, metadata) => { + chunks.push(chunk); + } + }; + let encoder = new AudioEncoder(encoder_init); + let config = { + codec: 'opus', + sampleRate: sample_rate, + numberOfChannels: 2, + bitrate: 256000, // 256kbit + }; + encoder.configure(config); + + let timestamp_us = 0; + const data_duration_s = total_duration_s / data_count; + const frames = data_duration_s * config.sampleRate; + for (let i = 0; i < data_count; i++) { + let buffer = new Float32Array(frames * config.numberOfChannels); + let data = new AudioData({ + timestamp: timestamp_us, + data: buffer, + numberOfChannels: config.numberOfChannels, + numberOfFrames: frames, + sampleRate: config.sampleRate, + format: 'f32-planar', + transfer: [buffer.buffer] + }); + timestamp_us += data_duration_s * 1_000_000; + assert_equals(buffer.length, 0, 'buffer.length after detach'); + encoder.encode(data); + } + await encoder.flush(); + encoder.close(); + assert_greater_than(chunks.length, 0); +}, 'Encoding from AudioData with transferred buffer'); + + +promise_test(async t => { + let unused_buffer = new ArrayBuffer(123); + let support = await ImageDecoder.isTypeSupported('image/png'); + assert_implements_optional( + support, 'Optional codec image/png not supported.'); + let buffer = await fetch('four-colors.png').then(response => { + return response.arrayBuffer(); + }); + + let decoder = new ImageDecoder( + {data: buffer, type: 'image/png', transfer: [buffer, unused_buffer]}); + assert_equals(buffer.byteLength, 0, 'buffer.byteLength after detach'); + assert_equals(unused_buffer.byteLength, 0, 'unused_buffer after detach'); + + let result = await decoder.decode(); + assert_equals(result.image.displayWidth, 320); + assert_equals(result.image.displayHeight, 240); +}, 'Test transfering ArrayBuffer to ImageDecoder.'); diff --git a/testing/web-platform/tests/webcodecs/utils.js b/testing/web-platform/tests/webcodecs/utils.js new file mode 100644 index 0000000000..f09334677a --- /dev/null +++ b/testing/web-platform/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/video-decoder.crossOriginIsolated.https.any.js b/testing/web-platform/tests/webcodecs/video-decoder.crossOriginIsolated.https.any.js new file mode 100644 index 0000000000..3232844a31 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-decoder.crossOriginIsolated.https.any.js @@ -0,0 +1,68 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +const testData = { + src: 'h264.mp4', + config: { + codec: 'avc1.64000b', + description: {offset: 9490, size: 45}, + codedWidth: 320, + codedHeight: 240, + displayAspectWidth: 320, + displayAspectHeight: 240, + } +}; + +// Create a view of an ArrayBuffer. +function view(buffer, {offset, size}) { + return new Uint8Array(buffer, offset, size); +} + +function testSharedArrayBufferDescription(t, useView) { + const data = testData; + + // Don't run test if the codec is not supported. + assert_equals("function", typeof VideoDecoder.isConfigSupported); + let supported = false; + return VideoDecoder.isConfigSupported({codec: data.config.codec}) + .catch(_ => { + assert_implements_optional(false, data.config.codec + ' unsupported'); + }) + .then(support => { + supported = support.supported; + assert_implements_optional( + supported, data.config.codec + ' unsupported'); + return fetch(data.src); + }) + .then(response => { + return response.arrayBuffer(); + }) + .then(buf => { + config = {...data.config}; + if (data.config.description) { + let desc = new SharedArrayBuffer(data.config.description.size); + let descView = new Uint8Array(desc); + descView.set(view(buf, data.config.description)); + config.description = useView ? descView : desc; + } + + // Support was verified above, so the description shouldn't change + // that. + return VideoDecoder.isConfigSupported(config); + }) + .then(support => { + assert_true(support.supported); + + const decoder = new VideoDecoder(getDefaultCodecInit(t)); + decoder.configure(config); + assert_equals(decoder.state, 'configured', 'state'); + }); +} + +promise_test(t => { + return testSharedArrayBufferDescription(t, /*useView=*/ false); +}, 'Test isConfigSupported() and configure() using a SharedArrayBuffer'); + +promise_test(t => { + return testSharedArrayBufferDescription(t, /*useView=*/ true); +}, 'Test isConfigSupported() and configure() using a Uint8Array(SharedArrayBuffer)'); diff --git a/testing/web-platform/tests/webcodecs/video-decoder.crossOriginIsolated.https.any.js.headers b/testing/web-platform/tests/webcodecs/video-decoder.crossOriginIsolated.https.any.js.headers new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-decoder.crossOriginIsolated.https.any.js.headers @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/testing/web-platform/tests/webcodecs/video-decoder.https.any.js b/testing/web-platform/tests/webcodecs/video-decoder.https.any.js new file mode 100644 index 0000000000..77a610bd4e --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-decoder.https.any.js @@ -0,0 +1,159 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +const invalidConfigs = [ + { + comment: 'Missing codec', + config: {}, + }, + { + comment: 'Empty codec', + config: {codec: ''}, + }, +]; // invalidConfigs + +invalidConfigs.forEach(entry => { + promise_test( + t => { + return promise_rejects_js( + t, TypeError, VideoDecoder.isConfigSupported(entry.config)); + }, + 'Test that VideoDecoder.isConfigSupported() rejects invalid config:' + + entry.comment); +}); + +invalidConfigs.forEach(entry => { + async_test( + t => { + let codec = new VideoDecoder(getDefaultCodecInit(t)); + assert_throws_js(TypeError, () => { + codec.configure(entry.config); + }); + t.done(); + }, + 'Test that VideoDecoder.configure() rejects invalid config:' + + entry.comment); +}); + +const arrayBuffer = new ArrayBuffer(12583); +const arrayBufferView = new DataView(arrayBuffer); + +const validButUnsupportedConfigs = [ + { + comment: 'Unrecognized codec', + config: {codec: 'bogus'}, + }, + { + comment: 'Unrecognized codec with dataview description', + config: { + codec: '7󠎢ﷺ۹.9', + description: arrayBufferView, + }, + }, + { + comment: 'Audio codec', + config: {codec: 'vorbis'}, + }, + { + comment: 'Ambiguous codec', + config: {codec: 'vp9'}, + }, + { + comment: 'Codec with bad casing', + config: {codec: 'Vp09.00.10.08'}, + }, + { + comment: 'Codec with MIME type', + config: {codec: 'video/webm; codecs="vp8"'}, + }, + { + comment: 'Possible future H264 codec string', + config: {codec: 'avc1.FF000b'}, + }, + { + comment: 'Possible future HEVC codec string', + config: {codec: 'hvc1.C99.6FFFFFF.L93'}, + }, + { + comment: 'Possible future VP9 codec string', + config: {codec: 'vp09.99.99.08'}, + }, + { + comment: 'Possible future AV1 codec string', + config: {codec: 'av01.9.99M.08'}, + }, +]; // validButUnsupportedConfigs + +validButUnsupportedConfigs.forEach(entry => { + promise_test( + t => { + return VideoDecoder.isConfigSupported(entry.config).then(support => { + assert_false(support.supported); + }); + }, + 'Test that VideoDecoder.isConfigSupported() doesn\'t support config: ' + + entry.comment); +}); + +validButUnsupportedConfigs.forEach(entry => { + promise_test( + t => { + let isErrorCallbackCalled = false; + let codec = new VideoDecoder({ + output: t.unreached_func('unexpected output'), + error: t.step_func(e => { + isErrorCallbackCalled = true; + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(codec.state, 'closed', 'state'); + }) + }); + codec.configure(entry.config); + return codec.flush() + .then(t.unreached_func('flush succeeded unexpectedly')) + .catch(t.step_func(e => { + assert_true(isErrorCallbackCalled, "isErrorCallbackCalled"); + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(codec.state, 'closed', 'state'); + })); + }, + 'Test that VideoDecoder.configure() doesn\'t support config: ' + + entry.comment); +}); + +promise_test(t => { + // VideoDecoderInit lacks required fields. + assert_throws_js(TypeError, () => { + new VideoDecoder({}); + }); + + // VideoDecoderInit has required fields. + let decoder = new VideoDecoder(getDefaultCodecInit(t)); + + assert_equals(decoder.state, 'unconfigured'); + + decoder.close(); + + return endAfterEventLoopTurn(); +}, 'Test VideoDecoder construction'); + +const validConfigs = [ + { + comment: 'valid codec with spaces', + config: {codec: ' vp09.00.10.08 '}, + }, +]; // validConfigs + +validConfigs.forEach(entry => { + promise_test( + async t => { + try { + await VideoDecoder.isConfigSupported(entry.config); + } catch (e) { + assert_true(false, entry.comment + ' should not throw'); + } + }, + 'Test that VideoDecoder.isConfigSupported() accepts config:' + + entry.comment); +}); diff --git a/testing/web-platform/tests/webcodecs/video-encoder-config.https.any.js b/testing/web-platform/tests/webcodecs/video-encoder-config.https.any.js new file mode 100644 index 0000000000..5011bfdd0a --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-encoder-config.https.any.js @@ -0,0 +1,279 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js + +const invalidConfigs = [ + { + comment: 'Missing codec', + config: { + width: 640, + height: 480, + }, + }, + { + comment: 'Empty codec', + config: { + codec: '', + width: 640, + height: 480, + }, + }, + { + comment: 'Width is 0', + config: { + codec: 'vp8', + width: 0, + height: 480, + }, + }, + { + comment: 'Height is 0', + config: { + codec: 'vp8', + width: 640, + height: 0, + }, + }, + { + comment: 'displayWidth is 0', + config: { + codec: 'vp8', + displayWidth: 0, + width: 640, + height: 480, + }, + }, + { + comment: 'displayHeight is 0', + config: { + codec: 'vp8', + width: 640, + displayHeight: 0, + height: 480, + }, + }, +]; + +invalidConfigs.forEach(entry => { + promise_test( + t => { + return promise_rejects_js( + t, TypeError, VideoEncoder.isConfigSupported(entry.config)); + }, + 'Test that VideoEncoder.isConfigSupported() rejects invalid config: ' + + entry.comment); +}); + +invalidConfigs.forEach(entry => { + async_test( + t => { + let codec = new VideoEncoder(getDefaultCodecInit(t)); + assert_throws_js(TypeError, () => { + codec.configure(entry.config); + }); + t.done(); + }, + 'Test that VideoEncoder.configure() rejects invalid config: ' + + entry.comment); +}); + +const validButUnsupportedConfigs = [ + { + comment: 'Invalid scalability mode', + config: {codec: 'vp8', width: 640, height: 480, scalabilityMode: 'ABC'} + }, + { + comment: 'Unrecognized codec', + config: { + codec: 'bogus', + width: 640, + height: 480, + }, + }, + { + comment: 'Codec with bad casing', + config: { + codec: 'vP8', + width: 640, + height: 480, + }, + }, + { + comment: 'Width is too large', + config: { + codec: 'vp8', + width: 1000000, + height: 480, + }, + }, + { + comment: 'Height is too large', + config: { + codec: 'vp8', + width: 640, + height: 1000000, + }, + }, + { + comment: 'Too strenuous accelerated encoding parameters', + config: { + codec: 'vp8', + hardwareAcceleration: 'prefer-hardware', + width: 20000, + height: 20000, + bitrate: 1, + framerate: 240, + } + }, + { + comment: 'Odd sized frames for H264', + config: { + codec: 'avc1.42001E', + width: 641, + height: 480, + bitrate: 1000000, + framerate: 24, + } + }, + { + comment: 'Possible future H264 codec string', + config: { + codec: 'avc1.FF000b', + width: 640, + height: 480, + }, + }, + { + comment: 'Possible future HEVC codec string', + config: { + codec: 'hvc1.C99.6FFFFFF.L93', + width: 640, + height: 480, + }, + }, + { + comment: 'Possible future VP9 codec string', + config: { + codec: 'vp09.99.99.08', + width: 640, + height: 480, + }, + }, + { + comment: 'Possible future AV1 codec string', + config: { + codec: 'av01.9.99M.08', + width: 640, + height: 480, + }, + }, +]; + +validButUnsupportedConfigs.forEach(entry => { + let config = entry.config; + promise_test( + async t => { + let support = await VideoEncoder.isConfigSupported(config); + assert_false(support.supported); + + let new_config = support.config; + assert_equals(new_config.codec, config.codec); + assert_equals(new_config.width, config.width); + assert_equals(new_config.height, config.height); + if (config.bitrate) + assert_equals(new_config.bitrate, config.bitrate); + if (config.framerate) + assert_equals(new_config.framerate, config.framerate); + }, + 'Test that VideoEncoder.isConfigSupported() doesn\'t support config: ' + + entry.comment); +}); + +validButUnsupportedConfigs.forEach(entry => { + promise_test( + t => { + let isErrorCallbackCalled = false; + let codec = new VideoEncoder({ + output: t.unreached_func('unexpected output'), + error: t.step_func_done(e => { + isErrorCallbackCalled = true; + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(codec.state, 'closed', 'state'); + }) + }); + codec.configure(entry.config); + return codec.flush() + .then(t.unreached_func('flush succeeded unexpectedly')) + .catch(t.step_func(e => { + assert_true(isErrorCallbackCalled, "isErrorCallbackCalled"); + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(codec.state, 'closed', 'state'); + })); + }, + 'Test that VideoEncoder.configure() doesn\'t support config: ' + + entry.comment); +}); + +const validConfigs = [ + { + codec: 'avc1.42001E', + hardwareAcceleration: 'no-preference', + width: 640, + height: 480, + bitrate: 5000000, + framerate: 24, + avc: {format: 'annexb'}, + futureConfigFeature: 'foo', + }, + { + codec: 'vp8', + hardwareAcceleration: 'no-preference', + width: 800, + height: 600, + bitrate: 7000000, + bitrateMode: 'variable', + framerate: 60, + scalabilityMode: 'L1T2', + futureConfigFeature: 'foo', + latencyMode: 'quality', + avc: {format: 'annexb'} + }, + { + codec: 'vp09.00.10.08', + hardwareAcceleration: 'no-preference', + width: 1280, + height: 720, + bitrate: 7000000, + bitrateMode: 'constant', + framerate: 25, + futureConfigFeature: 'foo', + latencyMode: 'realtime', + alpha: 'discard' + } +]; + +validConfigs.forEach(config => { + promise_test(async t => { + let support = await VideoEncoder.isConfigSupported(config); + assert_implements_optional(support.supported); + + let new_config = support.config; + assert_false(new_config.hasOwnProperty('futureConfigFeature')); + assert_equals(new_config.codec, config.codec); + assert_equals(new_config.width, config.width); + assert_equals(new_config.height, config.height); + if (config.bitrate) + assert_equals(new_config.bitrate, config.bitrate); + if (config.framerate) + assert_equals(new_config.framerate, config.framerate); + if (config.bitrateMode) + assert_equals(new_config.bitrateMode, config.bitrateMode); + if (config.latencyMode) + assert_equals(new_config.latencyMode, config.latencyMode); + if (config.alpha) + assert_equals(new_config.alpha, config.alpha); + if (config.avc) + assert_equals(new_config.avc.format, config.avc.format); + }, "VideoEncoder.isConfigSupported() supports:" + JSON.stringify(config)); +}); diff --git a/testing/web-platform/tests/webcodecs/video-encoder-content-hint.https.any.js b/testing/web-platform/tests/webcodecs/video-encoder-content-hint.https.any.js new file mode 100644 index 0000000000..cdc32fe3c6 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-encoder-content-hint.https.any.js @@ -0,0 +1,21 @@ +// META: global=window,dedicatedworker + +promise_test(async t => { + const config = { + codec: 'vp8', + width: 1280, + height: 720, + bitrate: 5000000, + bitrateMode: 'constant', + framerate: 25, + latencyMode: 'realtime', + contentHint: 'text', + }; + + let support = await VideoEncoder.isConfigSupported(config); + assert_equals(support.supported, true); + + let new_config = support.config; + assert_equals(new_config.codec, config.codec); + assert_equals(new_config.contentHint, 'text'); +}, 'Test that contentHint is recognized by VideoEncoder'); diff --git a/testing/web-platform/tests/webcodecs/video-encoder-flush.https.any.js b/testing/web-platform/tests/webcodecs/video-encoder-flush.https.any.js new file mode 100644 index 0000000000..8f1724bc85 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-encoder-flush.https.any.js @@ -0,0 +1,47 @@ +// META: global=window,dedicatedworker +// META: script=/common/media.js +// META: script=/webcodecs/utils.js +// META: script=/webcodecs/video-encoder-utils.js + +promise_test(async t => { + let codecInit = getDefaultCodecInit(t); + let encoderConfig = { + codec: 'vp8', + width: 640, + height: 480, + displayWidth: 800, + displayHeight: 600, + }; + + let outputs = 0; + let firstOutput = new Promise(resolve => { + codecInit.output = (chunk, metadata) => { + outputs++; + assert_equals(outputs, 1, 'outputs'); + encoder.reset(); + resolve(); + }; + }); + + let encoder = new VideoEncoder(codecInit); + encoder.configure(encoderConfig); + + let frame1 = createFrame(640, 480, 0); + let frame2 = createFrame(640, 480, 33333); + t.add_cleanup(() => { + frame1.close(); + frame2.close(); + }); + + encoder.encode(frame1); + encoder.encode(frame2); + const flushDone = encoder.flush(); + + // Wait for the first output, then reset. + await firstOutput; + + // Flush should have been synchronously rejected. + await promise_rejects_dom(t, 'AbortError', flushDone); + + assert_equals(outputs, 1, 'outputs'); +}, 'Test reset during flush'); diff --git a/testing/web-platform/tests/webcodecs/video-encoder-h264.https.any.js b/testing/web-platform/tests/webcodecs/video-encoder-h264.https.any.js new file mode 100644 index 0000000000..82370a8338 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-encoder-h264.https.any.js @@ -0,0 +1,69 @@ +// META: global=window,dedicatedworker +// META: script=/common/media.js +// META: script=/webcodecs/utils.js +// META: script=/webcodecs/video-encoder-utils.js +// META: variant=?baseline +// META: variant=?main +// META: variant=?high + +promise_test(async t => { + const codecString = { + '?baseline': 'avc1.42001e', + '?main': 'avc1.4d001e', + '?high': 'avc1.64001e', + }[location.search]; + + let encoderConfig = { + codec: codecString, + width: 640, + height: 480, + displayWidth: 800, + displayHeight: 600, + avc: {format: 'avc'}, // AVC makes it easy to check the profile. + }; + + let supported = false; + try { + const support = await VideoEncoder.isConfigSupported(encoderConfig); + supported = support.supported; + } catch (e) { + } + assert_implements_optional( + supported, `H264 ${location.search.substring(1)} profile unsupported`); + + let description = null; + let codecInit = { + error: t.unreached_func('Unexpected encoding error'), + output: (_, metadata) => { + assert_not_equals(metadata, null); + if (metadata.decoderConfig) + description = metadata.decoderConfig.description; + }, + }; + + let encoder = new VideoEncoder(codecInit); + encoder.configure(encoderConfig); + + let frame1 = createFrame(640, 480, 0); + let frame2 = createFrame(640, 480, 33333); + t.add_cleanup(() => { + frame1.close(); + frame2.close(); + }); + + encoder.encode(frame1); + encoder.encode(frame2); + + await encoder.flush(); + + assert_not_equals(description, null); + assert_not_equals(description.length, 0); + + // Profile is a hex code at xx in a codec string of form "avc1.xxyyzz". + let expectedProfileIndication = parseInt(codecString.substring(5, 7), 16); + + // See AVCDecoderConfigurationRecord in ISO/IEC 14496-15 for details. + // https://www.w3.org/TR/webcodecs-avc-codec-registration/#dom-avcbitstreamformat-avc + let profileIndication = new Uint8Array(description)[1]; + assert_equals(profileIndication, expectedProfileIndication); +}, 'Test that encoding with a specific H264 profile actually produces that profile.'); diff --git a/testing/web-platform/tests/webcodecs/video-encoder-utils.js b/testing/web-platform/tests/webcodecs/video-encoder-utils.js new file mode 100644 index 0000000000..0838260d31 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-encoder-utils.js @@ -0,0 +1,103 @@ +async function checkEncoderSupport(test, config) { + assert_equals("function", typeof VideoEncoder.isConfigSupported); + let supported = false; + try { + const support = await VideoEncoder.isConfigSupported(config); + supported = support.supported; + } catch (e) {} + + assert_implements_optional(supported, 'Unsupported config: ' + + JSON.stringify(config)); +} + +function fourColorsFrame(ctx, width, height, text) { + const kYellow = "#FFFF00"; + const kRed = "#FF0000"; + const kBlue = "#0000FF"; + const kGreen = "#00FF00"; + + ctx.fillStyle = kYellow; + ctx.fillRect(0, 0, width / 2, height / 2); + + ctx.fillStyle = kRed; + ctx.fillRect(width / 2, 0, width / 2, height / 2); + + ctx.fillStyle = kBlue; + ctx.fillRect(0, height / 2, width / 2, height / 2); + + ctx.fillStyle = kGreen; + ctx.fillRect(width / 2, height / 2, width / 2, height / 2); + + ctx.fillStyle = 'white'; + ctx.font = (height / 10) + 'px sans-serif'; + ctx.fillText(text, width / 2, height / 2); +} + +// Paints |count| black dots on the |ctx|, so their presence can be validated +// later. This is an analog of the most basic bar code. +function putBlackDots(ctx, width, height, count) { + ctx.fillStyle = 'black'; + const dot_size = 20; + const step = dot_size * 2; + + for (let i = 1; i <= count; i++) { + let x = i * step; + let y = step * (x / width + 1); + x %= width; + ctx.fillRect(x, y, dot_size, dot_size); + } +} + +// Validates that frame has |count| black dots in predefined places. +function validateBlackDots(frame, count) { + const width = frame.displayWidth; + const height = frame.displayHeight; + let cnv = new OffscreenCanvas(width, height); + var ctx = cnv.getContext('2d', {willReadFrequently: true}); + ctx.drawImage(frame, 0, 0); + const dot_size = 20; + const step = dot_size * 2; + + for (let i = 1; i <= count; i++) { + let x = i * step + dot_size / 2; + let y = step * (x / width + 1) + dot_size / 2; + x %= width; + + if (x) + x = x -1; + if (y) + y = y -1; + + let rgba = ctx.getImageData(x, y, 2, 2).data; + const tolerance = 60; + if ((rgba[0] > tolerance || rgba[1] > tolerance || rgba[2] > tolerance) + && (rgba[4] > tolerance || rgba[5] > tolerance || rgba[6] > tolerance) + && (rgba[8] > tolerance || rgba[9] > tolerance || rgba[10] > tolerance) + && (rgba[12] > tolerance || rgba[13] > tolerance || rgba[14] > tolerance)) { + // The dot is too bright to be a black dot. + return false; + } + } + return true; +} + +function createFrame(width, height, ts = 0) { + let duration = 33333; // 30fps + let text = ts.toString(); + let cnv = new OffscreenCanvas(width, height); + var ctx = cnv.getContext('2d'); + fourColorsFrame(ctx, width, height, text); + return new VideoFrame(cnv, { timestamp: ts, duration }); +} + +function createDottedFrame(width, height, dots, ts) { + if (ts === undefined) + ts = dots; + let duration = 33333; // 30fps + let text = ts.toString(); + let cnv = new OffscreenCanvas(width, height); + var ctx = cnv.getContext('2d'); + fourColorsFrame(ctx, width, height, text); + putBlackDots(ctx, width, height, dots); + return new VideoFrame(cnv, { timestamp: ts, duration }); +} diff --git a/testing/web-platform/tests/webcodecs/video-encoder.https.any.js b/testing/web-platform/tests/webcodecs/video-encoder.https.any.js new file mode 100644 index 0000000000..2746e60917 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-encoder.https.any.js @@ -0,0 +1,308 @@ +// META: global=window,dedicatedworker +// META: script=/common/media.js +// META: script=/webcodecs/utils.js +// META: script=/webcodecs/video-encoder-utils.js + +const defaultConfig = { + codec: 'vp8', + width: 640, + height: 480 +}; + +promise_test(t => { + // VideoEncoderInit lacks required fields. + assert_throws_js(TypeError, () => { new VideoEncoder({}); }); + + // VideoEncoderInit has required fields. + let encoder = new VideoEncoder(getDefaultCodecInit(t)); + + assert_equals(encoder.state, "unconfigured"); + + encoder.close(); + + return endAfterEventLoopTurn(); +}, 'Test VideoEncoder construction'); + +promise_test(async t => { + let output_chunks = []; + let codecInit = getDefaultCodecInit(t); + let decoderConfig = null; + let encoderConfig = { + codec: 'vp8', + width: 640, + height: 480, + displayWidth: 800, + displayHeight: 600, + }; + + codecInit.output = (chunk, metadata) => { + assert_not_equals(metadata, null); + if (metadata.decoderConfig) + decoderConfig = metadata.decoderConfig; + output_chunks.push(chunk); + } + + let encoder = new VideoEncoder(codecInit); + encoder.configure(encoderConfig); + + let frame1 = createFrame(640, 480, 0); + let frame2 = createFrame(640, 480, 33333); + t.add_cleanup(() => { + frame1.close(); + frame2.close(); + }); + + encoder.encode(frame1); + encoder.encode(frame2); + + await encoder.flush(); + + // Decoder config should be given with the first chunk + assert_not_equals(decoderConfig, null); + assert_equals(decoderConfig.codec, encoderConfig.codec); + assert_greater_than_equal(decoderConfig.codedHeight, encoderConfig.height); + assert_greater_than_equal(decoderConfig.codedWidth, encoderConfig.width); + assert_equals(decoderConfig.displayAspectHeight, encoderConfig.displayHeight); + assert_equals(decoderConfig.displayAspectWidth, encoderConfig.displayWidth); + assert_not_equals(decoderConfig.colorSpace.primaries, null); + assert_not_equals(decoderConfig.colorSpace.transfer, null); + assert_not_equals(decoderConfig.colorSpace.matrix, null); + assert_not_equals(decoderConfig.colorSpace.fullRange, null); + + assert_equals(output_chunks.length, 2); + assert_equals(output_chunks[0].timestamp, frame1.timestamp); + assert_equals(output_chunks[0].duration, frame1.duration); + assert_equals(output_chunks[1].timestamp, frame2.timestamp); + assert_equals(output_chunks[1].duration, frame2.duration); +}, 'Test successful configure(), encode(), and flush()'); + +promise_test(async t => { + let codecInit = getDefaultCodecInit(t); + let encoderConfig = { + codec: 'vp8', + width: 320, + height: 200 + }; + + codecInit.output = (chunk, metadata) => {} + + let encoder = new VideoEncoder(codecInit); + + // No encodes yet. + assert_equals(encoder.encodeQueueSize, 0); + + encoder.configure(encoderConfig); + + // Still no encodes. + assert_equals(encoder.encodeQueueSize, 0); + + const frames_count = 100; + let frames = []; + for (let i = 0; i < frames_count; i++) { + let frame = createFrame(320, 200, i * 16000); + frames.push(frame); + } + + let lastDequeueSize = Infinity; + encoder.ondequeue = () => { + assert_greater_than(lastDequeueSize, 0, "Dequeue event after queue empty"); + assert_greater_than(lastDequeueSize, encoder.encodeQueueSize, + "Dequeue event without decreased queue size"); + lastDequeueSize = encoder.encodeQueueSize; + }; + + for (let frame of frames) + encoder.encode(frame); + + assert_greater_than_equal(encoder.encodeQueueSize, 0); + assert_less_than_equal(encoder.encodeQueueSize, frames_count); + + await encoder.flush(); + // We can guarantee that all encodes are processed after a flush. + assert_equals(encoder.encodeQueueSize, 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 encodes. + lastDequeueSize = Infinity; + + for (let frame of frames) { + encoder.encode(frame); + frame.close(); + } + + assert_greater_than_equal(encoder.encodeQueueSize, 0); + encoder.reset(); + assert_equals(encoder.encodeQueueSize, 0); +}, 'encodeQueueSize test'); + + +promise_test(async t => { + let timestamp = 0; + let callbacks_before_reset = 0; + let callbacks_after_reset = 0; + const timestamp_step = 40000; + const expected_callbacks_before_reset = 3; + let codecInit = getDefaultCodecInit(t); + let original = createFrame(320, 200, 0); + let encoder = null; + let reset_completed = false; + codecInit.output = (chunk, metadata) => { + if (chunk.timestamp % 2 == 0) { + // pre-reset frames have even timestamp + callbacks_before_reset++; + if (callbacks_before_reset == expected_callbacks_before_reset) { + encoder.reset(); + reset_completed = true; + } + } else { + // after-reset frames have odd timestamp + callbacks_after_reset++; + } + } + + encoder = new VideoEncoder(codecInit); + encoder.configure(defaultConfig); + await encoder.flush(); + + // Send 10x frames to the encoder, call reset() on it after x outputs, + // and make sure no more chunks are emitted afterwards. + let encodes_before_reset = expected_callbacks_before_reset * 10; + for (let i = 0; i < encodes_before_reset; i++) { + let frame = new VideoFrame(original, { timestamp: timestamp }); + timestamp += timestamp_step; + encoder.encode(frame); + frame.close(); + } + + await t.step_wait(() => reset_completed, + "Reset() should be called by output callback", 10000, 1); + + assert_equals(callbacks_before_reset, expected_callbacks_before_reset); + assert_true(reset_completed); + assert_equals(encoder.encodeQueueSize, 0); + + let newConfig = { ...defaultConfig }; + newConfig.width = 800; + newConfig.height = 600; + encoder.configure(newConfig); + + const frames_after_reset = 5; + for (let i = 0; i < frames_after_reset; i++) { + let frame = createFrame(800, 600, timestamp + 1); + timestamp += timestamp_step; + encoder.encode(frame); + frame.close(); + } + await encoder.flush(); + + assert_equals(callbacks_after_reset, frames_after_reset, + "not all after-reset() outputs have been emitted"); + assert_equals(callbacks_before_reset, expected_callbacks_before_reset, + "pre-reset() outputs were emitter after reset() and flush()"); + assert_equals(encoder.encodeQueueSize, 0); +}, 'Test successful reset() and re-confiugre()'); + +promise_test(async t => { + let output_chunks = []; + const codecInit = { + output: chunk => output_chunks.push(chunk), + }; + const error = new Promise(resolve => codecInit.error = e => { + resolve(e); + }); + + let encoder = new VideoEncoder(codecInit); + + // No encodes yet. + assert_equals(encoder.encodeQueueSize, 0); + + let config = defaultConfig; + + encoder.configure(config); + + let frame1 = createFrame(640, 480, 0); + let frame2 = createFrame(640, 480, 33333); + + encoder.encode(frame1); + encoder.configure(config); + + encoder.encode(frame2); + + await encoder.flush(); + + // We can guarantee that all encodes are processed after a flush. + assert_equals(encoder.encodeQueueSize, 0, "queue size after encode"); + + assert_equals(output_chunks.length, 2, "number of chunks"); + assert_equals(output_chunks[0].timestamp, frame1.timestamp); + assert_equals(output_chunks[1].timestamp, frame2.timestamp); + + output_chunks = []; + + let frame3 = createFrame(640, 480, 66666); + + encoder.encode(frame3); + + let badConfig = { ...defaultConfig }; + badConfig.codec = ''; + assert_throws_js(TypeError, () => encoder.configure(badConfig)); + + badConfig.codec = 'bogus'; + encoder.configure(badConfig); + let e = await error; + assert_true(e instanceof DOMException); + assert_equals(e.name, 'NotSupportedError'); + assert_equals(encoder.state, 'closed', 'state'); + + // We may or may not have received frame3 before closing. +}, 'Test successful encode() after re-configure().'); + +promise_test(async t => { + let encoder = new VideoEncoder(getDefaultCodecInit(t)); + + let frame = createFrame(640, 480, 0); + + return testClosedCodec(t, encoder, defaultConfig, frame); +}, 'Verify closed VideoEncoder operations'); + +promise_test(async t => { + let encoder = new VideoEncoder(getDefaultCodecInit(t)); + + let frame = createFrame(640, 480, 0); + + return testUnconfiguredCodec(t, encoder, frame); +}, 'Verify unconfigured VideoEncoder operations'); + +promise_test(async t => { + let encoder = new VideoEncoder(getDefaultCodecInit(t)); + + let frame = createFrame(640, 480, 0); + frame.close(); + + encoder.configure(defaultConfig); + + assert_throws_js(TypeError, () => { + encoder.encode(frame); + }); +}, 'Verify encoding closed frames throws.'); + +promise_test(async t => { + let output_chunks = []; + let codecInit = getDefaultCodecInit(t); + codecInit.output = chunk => output_chunks.push(chunk); + + let encoder = new VideoEncoder(codecInit); + let config = defaultConfig; + encoder.configure(config); + + let frame = createFrame(640, 480, -10000); + encoder.encode(frame); + frame.close(); + await encoder.flush(); + encoder.close(); + assert_equals(output_chunks.length, 1); + assert_equals(output_chunks[0].timestamp, -10000, "first chunk timestamp"); + assert_greater_than(output_chunks[0].byteLength, 0); +}, 'Encode video with negative timestamp'); diff --git a/testing/web-platform/tests/webcodecs/video-frame-serialization.any.js b/testing/web-platform/tests/webcodecs/video-frame-serialization.any.js new file mode 100644 index 0000000000..4968c43cda --- /dev/null +++ b/testing/web-platform/tests/webcodecs/video-frame-serialization.any.js @@ -0,0 +1,139 @@ +// META: global=window,dedicatedworker +// META: script=/common/media.js +// META: script=/webcodecs/utils.js + +var defaultInit = { + timestamp : 100, + duration : 33, +} + +function createDefaultVideoFrame() { + let image = makeImageBitmap(32,16); + + return new VideoFrame(image, defaultInit); +} + +test(t => { + let frame = createDefaultVideoFrame(); + + let clone = frame.clone(); + + assert_equals(frame.timestamp, clone.timestamp); + assert_equals(frame.duration, clone.duration); + assert_equals(frame.visibleRect.left, clone.visibleRect.left); + assert_equals(frame.visibleRect.top, clone.visibleRect.top); + assert_equals(frame.visibleRect.width, clone.visibleRect.width); + assert_equals(frame.visibleRect.height, clone.visibleRect.height); + + frame.close(); + assert_true(isFrameClosed(frame)); + clone.close(); + assert_true(isFrameClosed(clone)); +}, 'Test we can clone a VideoFrame.'); + +test(t => { + let frame = createDefaultVideoFrame(); + + let copy = frame; + let clone = frame.clone(); + + frame.close(); + + assert_equals(copy.timestamp, defaultInit.timestamp); + assert_equals(copy.duration, defaultInit.duration); + assert_true(isFrameClosed(copy)); + assert_equals(clone.timestamp, defaultInit.timestamp); + assert_false(isFrameClosed(clone)); + + clone.close(); +}, 'Verify closing a frame doesn\'t affect its clones.'); + +test(t => { + let frame = createDefaultVideoFrame(); + + frame.close(); + + assert_throws_dom("InvalidStateError", () => { + let clone = frame.clone(); + }); +}, 'Verify cloning a closed frame throws.'); + +async_test(t => { + let localFrame = createDefaultVideoFrame(); + + let channel = new MessageChannel(); + let localPort = channel.port1; + let externalPort = channel.port2; + + externalPort.onmessage = t.step_func((e) => { + let externalFrame = e.data; + externalFrame.close(); + externalPort.postMessage("Done"); + }) + + localPort.onmessage = t.step_func_done((e) => { + assert_equals(localFrame.timestamp, defaultInit.timestamp); + localFrame.close(); + }) + + localPort.postMessage(localFrame); +}, 'Verify closing frames does not propagate accross contexts.'); + +async_test(t => { + let localFrame = createDefaultVideoFrame(); + + let channel = new MessageChannel(); + let localPort = channel.port1; + let externalPort = channel.port2; + + externalPort.onmessage = t.step_func_done((e) => { + let externalFrame = e.data; + assert_equals(externalFrame.timestamp, defaultInit.timestamp); + externalFrame.close(); + }) + + localPort.postMessage(localFrame, [localFrame]); + assert_true(isFrameClosed(localFrame)); +}, 'Verify transferring frames closes them.'); + +async_test(t => { + let localFrame = createDefaultVideoFrame(); + + let channel = new MessageChannel(); + let localPort = channel.port1; + + localPort.onmessage = t.unreached_func(); + + localFrame.close(); + + assert_throws_dom("DataCloneError", () => { + localPort.postMessage(localFrame); + }); + + t.done(); +}, 'Verify posting closed frames throws.'); + +promise_test(async t => { + const open = indexedDB.open('VideoFrameTestDB', 1); + open.onerror = t.unreached_func('open should succeed'); + open.onupgradeneeded = (event) => { + let db = event.target.result; + db.createObjectStore('MyVideoFrames', { keyPath: 'id' }); + }; + let db = await new Promise((resolve) => { + open.onsuccess = (e) => { + resolve(e.target.result); + }; + }); + t.add_cleanup(() => { + db.close(); + indexedDB.deleteDatabase(db.name); + }); + + let transaction = db.transaction(['MyVideoFrames'], 'readwrite'); + const store = transaction.objectStore('MyVideoFrames'); + let frame = createDefaultVideoFrame(); + assert_throws_dom("DataCloneError", () => { + store.add(frame); + }); +}, 'Verify storing a frame throws.'); diff --git a/testing/web-platform/tests/webcodecs/videoColorSpace.any.js b/testing/web-platform/tests/webcodecs/videoColorSpace.any.js new file mode 100644 index 0000000000..3af828a5bd --- /dev/null +++ b/testing/web-platform/tests/webcodecs/videoColorSpace.any.js @@ -0,0 +1,47 @@ +// META: global=window,dedicatedworker + +const VIDEO_COLOR_SPACE_SETS = { + primaries: ['bt709', 'bt470bg', 'smpte170m', 'bt2020', 'smpte432'], + transfer: ['bt709', 'smpte170m', 'iec61966-2-1', 'linear', 'pq', 'hlg'], + matrix: ['rgb', 'bt709', 'bt470bg', 'smpte170m', 'bt2020-ncl'], + fullRange: [true, false], +}; + +function generateAllCombinations() { + const keys = Object.keys(VIDEO_COLOR_SPACE_SETS); + let colorSpaces = []; + generateAllCombinationsHelper(keys, 0, {}, colorSpaces); + return colorSpaces; +} + +function generateAllCombinationsHelper(keys, keyIndex, colorSpace, results) { + if (keyIndex >= keys.length) { + // Push the copied object since the colorSpace will be reused. + results.push(Object.assign({}, colorSpace)); + return; + } + + const prop = keys[keyIndex]; + // case 1: Skip this property. + generateAllCombinationsHelper(keys, keyIndex + 1, colorSpace, results); + // case 2: Set this property with a valid value. + for (const val of VIDEO_COLOR_SPACE_SETS[prop]) { + colorSpace[prop] = val; + generateAllCombinationsHelper(keys, keyIndex + 1, colorSpace, results); + delete colorSpace[prop]; + } +} + +test(t => { + let colorSpaces = generateAllCombinations(); + for (const colorSpace of colorSpaces) { + let vcs = new VideoColorSpace(colorSpace); + let json = vcs.toJSON(); + for (const k of Object.keys(json)) { + assert_equals( + json[k], + colorSpace.hasOwnProperty(k) ? colorSpace[k] : null + ); + } + } +}, 'Test VideoColorSpace toJSON() works.'); diff --git a/testing/web-platform/tests/webcodecs/videoDecoder-codec-specific.https.any.js b/testing/web-platform/tests/webcodecs/videoDecoder-codec-specific.https.any.js new file mode 100644 index 0000000000..a3acb82ab2 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/videoDecoder-codec-specific.https.any.js @@ -0,0 +1,615 @@ +// META: global=window,dedicatedworker +// META: variant=?av1 +// META: variant=?vp8 +// META: variant=?vp9 +// META: variant=?h264_avc +// META: variant=?h264_annexb +// META: variant=?h265_hevc +// META: variant=?h265_annexb + +const AV1_DATA = { + src: 'av1.mp4', + config: { + codec: 'av01.0.04M.08', + codedWidth: 320, + codedHeight: 240, + visibleRect: {x: 0, y: 0, width: 320, height: 240}, + displayWidth: 320, + displayHeight: 240, + }, + chunks: [ + {offset: 48, size: 1938}, {offset: 1986, size: 848}, + {offset: 2834, size: 3}, {offset: 2837, size: 47}, {offset: 2884, size: 3}, + {offset: 2887, size: 116}, {offset: 3003, size: 3}, + {offset: 3006, size: 51}, {offset: 3057, size: 25}, + {offset: 3082, size: 105} + ] +}; + +const VP8_DATA = { + src: 'vp8.webm', + config: { + codec: 'vp8', + codedWidth: 320, + codedHeight: 240, + visibleRect: {x: 0, y: 0, width: 320, height: 240}, + displayWidth: 320, + displayHeight: 240, + }, + chunks: [ + {offset: 522, size: 4826}, {offset: 5355, size: 394}, + {offset: 5756, size: 621}, {offset: 6384, size: 424}, + {offset: 6815, size: 532}, {offset: 7354, size: 655}, + {offset: 8016, size: 670}, {offset: 8693, size: 2413}, + {offset: 11113, size: 402}, {offset: 11522, size: 686} + ] +}; + +const VP9_DATA = { + src: 'vp9.mp4', + // TODO(sandersd): Verify that the file is actually level 1. + config: { + codec: 'vp09.00.10.08', + codedWidth: 320, + codedHeight: 240, + displayAspectWidth: 320, + displayAspectHeight: 240, + }, + chunks: [ + {offset: 44, size: 3315}, {offset: 3359, size: 203}, + {offset: 3562, size: 245}, {offset: 3807, size: 172}, + {offset: 3979, size: 312}, {offset: 4291, size: 170}, + {offset: 4461, size: 195}, {offset: 4656, size: 181}, + {offset: 4837, size: 356}, {offset: 5193, size: 159} + ] +}; + +const H264_AVC_DATA = { + src: 'h264.mp4', + config: { + codec: 'avc1.64000b', + description: {offset: 9490, size: 45}, + codedWidth: 320, + codedHeight: 240, + displayAspectWidth: 320, + displayAspectHeight: 240, + }, + chunks: [ + {offset: 48, size: 4140}, {offset: 4188, size: 604}, + {offset: 4792, size: 475}, {offset: 5267, size: 561}, + {offset: 5828, size: 587}, {offset: 6415, size: 519}, + {offset: 6934, size: 532}, {offset: 7466, size: 523}, + {offset: 7989, size: 454}, {offset: 8443, size: 528} + ] +}; + +const H264_ANNEXB_DATA = { + src: 'h264.annexb', + config: { + codec: 'avc1.64000b', + codedWidth: 320, + codedHeight: 240, + displayAspectWidth: 320, + displayAspectHeight: 240, + }, + chunks: [ + {offset: 0, size: 4175}, {offset: 4175, size: 602}, + {offset: 4777, size: 473}, {offset: 5250, size: 559}, + {offset: 5809, size: 585}, {offset: 6394, size: 517}, + {offset: 6911, size: 530}, {offset: 7441, size: 521}, + {offset: 7962, size: 452}, {offset: 8414, size: 526} + ] +}; + +const H265_HEVC_DATA = { + src: 'h265.mp4', + config: { + codec: 'hev1.1.6.L60.90', + description: {offset: 5821, size: 2406}, + codedWidth: 320, + codedHeight: 240, + displayAspectWidth: 320, + displayAspectHeight: 240, + }, + chunks: [ + {offset: 44, size: 2515}, {offset: 2559, size: 279}, + {offset: 2838, size: 327}, {offset: 3165, size: 329}, + {offset: 3494, size: 308}, {offset: 3802, size: 292}, + {offset: 4094, size: 352}, {offset: 4446, size: 296}, + {offset: 4742, size: 216}, {offset: 4958, size: 344} + ] +}; + +const H265_ANNEXB_DATA = { + src: 'h265.annexb', + config: { + codec: 'hev1.1.6.L60.90', + codedWidth: 320, + codedHeight: 240, + displayAspectWidth: 320, + displayAspectHeight: 240, + }, + chunks: [ + {offset: 0, size: 4894}, {offset: 4894, size: 279}, + {offset: 5173, size: 327}, {offset: 5500, size: 329}, + {offset: 5829, size: 308}, {offset: 6137, size: 292}, + {offset: 6429, size: 352}, {offset: 6781, size: 296}, + {offset: 7077, size: 216}, {offset: 7293, size: 344} + ] +}; + +// Allows mutating `callbacks` after constructing the VideoDecoder, wraps calls +// in t.step(). +function createVideoDecoder(t, callbacks) { + return new VideoDecoder({ + 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()'); + } + } + }); +} + +function createCorruptChunk(index) { + let bad_data = CHUNK_DATA[index]; + for (var i = 0; i < bad_data.byteLength; i += 4) + bad_data[i] = 0xFF; + return new EncodedVideoChunk( + {type: 'delta', timestamp: index, data: bad_data}); +} + +// Create a view of an ArrayBuffer. +function view(buffer, {offset, size}) { + return new Uint8Array(buffer, offset, size); +} + +async function checkImplements() { + // Don't run any tests if the codec is not supported. + assert_equals("function", typeof VideoDecoder.isConfigSupported); + let supported = false; + try { + // TODO(sandersd): To properly support H.264 in AVC format, this should + // include the `description`. For now this test assumes that H.264 Annex B + // support is the same as H.264 AVC support. + const support = + await VideoDecoder.isConfigSupported({codec: CONFIG.codec}); + supported = support.supported; + } catch (e) { + } + assert_implements_optional(supported, CONFIG.codec + ' unsupported'); +} + +let CONFIG = null; +let CHUNK_DATA = null; +let CHUNKS = null; +promise_setup(async () => { + const data = { + '?av1': AV1_DATA, + '?vp8': VP8_DATA, + '?vp9': VP9_DATA, + '?h264_avc': H264_AVC_DATA, + '?h264_annexb': H264_ANNEXB_DATA, + '?h265_hevc': H265_HEVC_DATA, + '?h265_annexb': H265_ANNEXB_DATA + }[location.search]; + + // 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 = data.chunks.map((chunk, i) => view(buf, chunk)); + + CHUNKS = CHUNK_DATA.map( + (data, i) => new EncodedVideoChunk( + {type: i == 0 ? 'key' : 'delta', timestamp: i, duration: 1, data})); +}); + +promise_test(async t => { + await checkImplements(); + const support = await VideoDecoder.isConfigSupported(CONFIG); + assert_true(support.supported, 'supported'); +}, 'Test isConfigSupported()'); + +promise_test(async t => { + await checkImplements(); + // TODO(sandersd): Create a 1080p `description` for H.264 in AVC format. + // This version is testing only the H.264 Annex B path. + const config = { + codec: CONFIG.codec, + codedWidth: 1920, + codedHeight: 1088, + displayAspectWidth: 1920, + displayAspectHeight: 1080, + }; + + const support = await VideoDecoder.isConfigSupported(config); + assert_true(support.supported, 'supported'); +}, 'Test isConfigSupported() with 1080p crop'); + +promise_test(async t => { + await checkImplements(); + // Define a valid config that includes a hypothetical `futureConfigFeature`, + // which is not yet recognized by the User Agent. + const config = { + ...CONFIG, + colorSpace: {primaries: 'bt709'}, + futureConfigFeature: 'foo', + }; + + // The UA will evaluate validConfig as being "valid", ignoring the + // `futureConfigFeature` it doesn't recognize. + const support = await VideoDecoder.isConfigSupported(config); + assert_true(support.supported, 'supported'); + assert_equals(support.config.codec, config.codec, 'codec'); + assert_equals(support.config.codedWidth, config.codedWidth, 'codedWidth'); + assert_equals(support.config.codedHeight, config.codedHeight, 'codedHeight'); + assert_equals(support.config.displayAspectWidth, config.displayAspectWidth, 'displayAspectWidth'); + assert_equals(support.config.displayAspectHeight, config.displayAspectHeight, 'displayAspectHeight'); + assert_equals(support.config.colorSpace.primaries, config.colorSpace.primaries, 'color primaries'); + assert_equals(support.config.colorSpace.transfer, null, 'color transfer'); + assert_equals(support.config.colorSpace.matrix, null, 'color matrix'); + assert_equals(support.config.colorSpace.fullRange, null, 'color range'); + assert_false(support.config.hasOwnProperty('futureConfigFeature'), 'futureConfigFeature'); + + if (config.description) { + // The description must be copied. + assert_false( + support.config.description === config.description, + 'description is unique'); + assert_array_equals( + new Uint8Array(support.config.description, 0), + new Uint8Array(config.description, 0), 'description'); + } else { + assert_false(support.config.hasOwnProperty('description'), 'description'); + } +}, 'Test that isConfigSupported() returns a parsed configuration'); + +promise_test(async t => { + await checkImplements(); + async function test(t, config, description) { + await promise_rejects_js( + t, TypeError, VideoDecoder.isConfigSupported(config), description); + + const decoder = createVideoDecoder(t); + assert_throws_js(TypeError, () => decoder.configure(config), description); + assert_equals(decoder.state, 'unconfigured', 'state'); + } + + await test(t, {...CONFIG, codedWidth: 0}, 'invalid codedWidth'); + await test(t, {...CONFIG, displayAspectWidth: 0}, 'invalid displayAspectWidth'); +}, 'Test invalid configs'); + +promise_test(async t => { + await checkImplements(); + const decoder = createVideoDecoder(t); + decoder.configure(CONFIG); + assert_equals(decoder.state, 'configured', 'state'); +}, 'Test configure()'); + +promise_test(async t => { + await checkImplements(); + const callbacks = {}; + const decoder = createVideoDecoder(t, callbacks); + decoder.configure(CONFIG); + decoder.decode(CHUNKS[0]); + + let outputs = 0; + callbacks.output = frame => { + outputs++; + assert_equals(frame.timestamp, CHUNKS[0].timestamp, 'timestamp'); + assert_equals(frame.duration, CHUNKS[0].duration, 'duration'); + frame.close(); + }; + + await decoder.flush(); + assert_equals(outputs, 1, 'outputs'); +}, 'Decode a key frame'); + +promise_test(async t => { + await checkImplements(); + const callbacks = {}; + const decoder = createVideoDecoder(t, callbacks); + decoder.configure(CONFIG); + + // Ensure type value is verified. + assert_equals(CHUNKS[1].type, 'delta'); + assert_throws_dom('DataError', () => decoder.decode(CHUNKS[1], 'decode')); +}, 'Decode a non key frame first fails'); + +promise_test(async t => { + await checkImplements(); + const callbacks = {}; + const decoder = createVideoDecoder(t, callbacks); + decoder.configure(CONFIG); + for (let i = 0; i < 16; i++) { + decoder.decode(new EncodedVideoChunk( + {type: 'key', timestamp: 0, data: CHUNK_DATA[0]})); + } + assert_greater_than(decoder.decodeQueueSize, 0); + + // Wait for the first output, then reset the decoder. + let outputs = 0; + await new Promise(resolve => { + callbacks.output = frame => { + outputs++; + assert_equals(outputs, 1, 'outputs'); + assert_equals(frame.timestamp, 0, 'timestamp'); + frame.close(); + decoder.reset(); + assert_equals(decoder.decodeQueueSize, 0, 'decodeQueueSize'); + resolve(); + }; + }); + + decoder.configure(CONFIG); + for (let i = 0; i < 4; i++) { + decoder.decode(new EncodedVideoChunk( + {type: 'key', timestamp: 1, data: CHUNK_DATA[0]})); + } + + // Expect future outputs to come from after the reset. + callbacks.output = frame => { + outputs++; + assert_equals(frame.timestamp, 1, 'timestamp'); + frame.close(); + }; + + await decoder.flush(); + assert_equals(outputs, 5); + assert_equals(decoder.decodeQueueSize, 0); +}, 'Verify reset() suppresses outputs'); + +promise_test(async t => { + await checkImplements(); + const decoder = createVideoDecoder(t); + assert_equals(decoder.state, 'unconfigured'); + + decoder.reset(); + assert_equals(decoder.state, 'unconfigured'); + assert_throws_dom( + 'InvalidStateError', () => decoder.decode(CHUNKS[0]), 'decode'); + await promise_rejects_dom(t, 'InvalidStateError', decoder.flush(), 'flush'); +}, 'Test unconfigured VideoDecoder operations'); + +promise_test(async t => { + await checkImplements(); + const decoder = createVideoDecoder(t); + decoder.close(); + assert_equals(decoder.state, 'closed'); + assert_throws_dom( + 'InvalidStateError', () => decoder.configure(CONFIG), 'configure'); + assert_throws_dom('InvalidStateError', () => decoder.reset(), 'reset'); + assert_throws_dom('InvalidStateError', () => decoder.close(), 'close'); + assert_throws_dom( + 'InvalidStateError', () => decoder.decode(CHUNKS[0]), 'decode'); + await promise_rejects_dom(t, 'InvalidStateError', decoder.flush(), 'flush'); +}, 'Test closed VideoDecoder operations'); + +promise_test(async t => { + await checkImplements(); + const callbacks = {}; + + let errors = 0; + let gotError = new Promise(resolve => callbacks.error = e => { + errors++; + resolve(e); + }); + callbacks.output = frame => { frame.close(); }; + + const decoder = createVideoDecoder(t, callbacks); + decoder.configure(CONFIG); + decoder.decode(CHUNKS[0]); // Decode keyframe first. + decoder.decode(new EncodedVideoChunk( + {type: 'key', timestamp: 1, data: new ArrayBuffer(0)})); + + await promise_rejects_dom(t, "EncodingError", + decoder.flush().catch((e) => { + assert_equals(errors, 1); + throw e; + }) + ); + + let e = await gotError; + assert_true(e instanceof DOMException); + assert_equals(e.name, 'EncodingError'); + assert_equals(decoder.state, 'closed', 'state'); +}, 'Decode empty frame'); + + +promise_test(async t => { + await checkImplements(); + const callbacks = {}; + + let errors = 0; + let gotError = new Promise(resolve => callbacks.error = e => { + errors++; + resolve(e); + }); + + let outputs = 0; + callbacks.output = frame => { + outputs++; + frame.close(); + }; + + const decoder = createVideoDecoder(t, callbacks); + decoder.configure(CONFIG); + decoder.decode(CHUNKS[0]); // Decode keyframe first. + decoder.decode(createCorruptChunk(2)); + + await promise_rejects_dom(t, "EncodingError", + decoder.flush().catch((e) => { + assert_equals(errors, 1); + throw e; + }) + ); + + assert_less_than_equal(outputs, 1); + let e = await gotError; + assert_true(e instanceof DOMException); + assert_equals(e.name, 'EncodingError'); + assert_equals(decoder.state, 'closed', 'state'); +}, 'Decode corrupt frame'); + +promise_test(async t => { + await checkImplements(); + const decoder = createVideoDecoder(t); + + decoder.configure(CONFIG); + decoder.decode(CHUNKS[0]); // Decode keyframe first. + decoder.decode(createCorruptChunk(1)); + + let flushDone = decoder.flush(); + decoder.close(); + + // Flush should have been synchronously rejected, with no output() or error() + // callbacks. + await promise_rejects_dom(t, 'AbortError', flushDone); +}, 'Close while decoding corrupt frame'); + +promise_test(async t => { + await checkImplements(); + const callbacks = {}; + const decoder = createVideoDecoder(t, callbacks); + + decoder.configure(CONFIG); + decoder.decode(CHUNKS[0]); + + let outputs = 0; + callbacks.output = frame => { + outputs++; + frame.close(); + }; + + 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 => { + await checkImplements(); + const callbacks = {}; + const decoder = createVideoDecoder(t, callbacks); + + decoder.configure(CONFIG); + decoder.decode(new EncodedVideoChunk( + {type: 'key', timestamp: -42, data: CHUNK_DATA[0]})); + + let outputs = 0; + callbacks.output = frame => { + outputs++; + assert_equals(frame.timestamp, -42, 'timestamp'); + frame.close(); + }; + + await decoder.flush(); + assert_equals(outputs, 1, 'outputs'); +}, 'Test decoding a with negative timestamp'); + +promise_test(async t => { + await checkImplements(); + const callbacks = {}; + const decoder = createVideoDecoder(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 => { + await checkImplements(); + const callbacks = {}; + const decoder = createVideoDecoder(t, callbacks); + + decoder.configure({...CONFIG, optimizeForLatency: true}); + decoder.decode(CHUNKS[0]); + + // The frame should be output without flushing. + await new Promise(resolve => { + callbacks.output = frame => { + frame.close(); + resolve(); + }; + }); +}, 'Test low-latency decoding'); + +promise_test(async t => { + await checkImplements(); + const callbacks = {}; + callbacks.output = frame => { frame.close(); }; + const decoder = createVideoDecoder(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); +}, 'VideoDecoder decodeQueueSize test'); diff --git a/testing/web-platform/tests/webcodecs/videoFrame-alpha.any.js b/testing/web-platform/tests/webcodecs/videoFrame-alpha.any.js new file mode 100644 index 0000000000..f4c4dfa737 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/videoFrame-alpha.any.js @@ -0,0 +1,50 @@ +// META: global=window,dedicatedworker + +function makeRGBACanvas() { + let canvas = new OffscreenCanvas(32, 32, {alpha: true}); + let ctx = canvas.getContext('2d'); + + // Opaque red quadrant. + ctx.fillStyle = 'rgba(255, 0, 0, 255)'; + ctx.fillRect(0, 0, 16, 16); + + // Opaque blue quadrant. + ctx.fillStyle = 'rgba(0, 255, 0, 255)'; + ctx.fillRect(16, 0, 16, 16); + + // Opaque green quadrant. + ctx.fillStyle = 'rgba(0, 0, 255, 255)'; + ctx.fillRect(0, 16, 16, 16); + + // Remaining quadrant should be transparent black. + return canvas; +} + +function getPixel(ctx, x, y) { + let data = ctx.getImageData(x, y, 1, 1).data; + return data[0] * 2 ** 24 + data[1] * 2 ** 16 + data[2] * 2 ** 8 + data[3]; +} + +function verifyPicture(picture) { + let canvas = new OffscreenCanvas(32, 32, {alpha: true}); + let ctx = canvas.getContext('2d'); + ctx.drawImage(picture, 0, 0); + assert_equals(getPixel(ctx, 8, 8), 0xFF0000FF); + assert_equals(getPixel(ctx, 24, 8), 0x00FF00FF); + assert_equals(getPixel(ctx, 8, 24), 0x0000FFFF); + assert_equals(getPixel(ctx, 24, 24), 0x00000000); +} + +promise_test(async () => { + let src = makeRGBACanvas(); + let frame = new VideoFrame(src, {alpha: 'keep', timestamp: 0}); + verifyPicture(frame); + verifyPicture(await createImageBitmap(frame)); +}, 'OffscreenCanvas source preserves alpha'); + +promise_test(async () => { + let src = makeRGBACanvas().transferToImageBitmap(); + let frame = new VideoFrame(src, {alpha: 'keep', timestamp: 0}); + verifyPicture(frame); + verifyPicture(await createImageBitmap(frame)); +}, 'ImageBitmap source preserves alpha'); diff --git a/testing/web-platform/tests/webcodecs/videoFrame-canvasImageSource.html b/testing/web-platform/tests/webcodecs/videoFrame-canvasImageSource.html new file mode 100644 index 0000000000..717a0a3643 --- /dev/null +++ b/testing/web-platform/tests/webcodecs/videoFrame-canvasImageSource.html @@ -0,0 +1,160 @@ +Test VideoFrame creation from CanvasImageSource. + + + + + + + + + + + + diff --git a/testing/web-platform/tests/webcodecs/videoFrame-construction.any.js b/testing/web-platform/tests/webcodecs/videoFrame-construction.any.js new file mode 100644 index 0000000000..9f2929aede --- /dev/null +++ b/testing/web-platform/tests/webcodecs/videoFrame-construction.any.js @@ -0,0 +1,753 @@ +// META: global=window,dedicatedworker +// META: script=/webcodecs/utils.js +// META: script=/webcodecs/videoFrame-utils.js + +test(t => { + let image = makeImageBitmap(32, 16); + let frame = new VideoFrame(image, {timestamp: 10}); + + assert_equals(frame.timestamp, 10, 'timestamp'); + assert_equals(frame.duration, null, 'duration'); + assert_equals(frame.visibleRect.width, 32, 'visibleRect.width'); + assert_equals(frame.visibleRect.height, 16, 'visibleRect.height'); + assert_equals(frame.displayWidth, 32, 'displayWidth'); + assert_equals(frame.displayHeight, 16, 'displayHeight'); + + frame.close(); +}, 'Test we can construct a VideoFrame.'); + +test(t => { + let image = makeImageBitmap(32, 16); + let frame = new VideoFrame(image, {timestamp: 10, duration: 15}); + frame.close(); + + assert_equals(frame.format, null, 'format') + assert_equals(frame.timestamp, 10, 'timestamp'); + assert_equals(frame.duration, 15, 'duration'); + assert_equals(frame.codedWidth, 0, 'codedWidth'); + assert_equals(frame.codedHeight, 0, 'codedHeight'); + assert_equals(frame.visibleRect, null, 'visibleRect'); + assert_equals(frame.displayWidth, 0, 'displayWidth'); + assert_equals(frame.displayHeight, 0, 'displayHeight'); + assert_equals(frame.colorSpace.primaries, null, 'colorSpace.primaries'); + assert_equals(frame.colorSpace.transfer, null, 'colorSpace.transfer'); + assert_equals(frame.colorSpace.matrix, null, 'colorSpace.matrix'); + assert_equals(frame.colorSpace.fullRange, null, 'colorSpace.fullRange'); + assert_true(isFrameClosed(frame)); + + assert_throws_dom('InvalidStateError', () => frame.clone()); +}, 'Test closed VideoFrame.'); + +test(t => { + let image = makeImageBitmap(32, 16); + let frame = new VideoFrame(image, {timestamp: -10}); + assert_equals(frame.timestamp, -10, 'timestamp'); + frame.close(); +}, 'Test we can construct a VideoFrame with a negative timestamp.'); + +promise_test(async t => { + verifyTimestampRequiredToConstructFrame(makeImageBitmap(1, 1)); +}, 'Test that timestamp is required when constructing VideoFrame from ImageBitmap'); + +promise_test(async t => { + verifyTimestampRequiredToConstructFrame(makeOffscreenCanvas(16, 16)); +}, 'Test that timestamp is required when constructing VideoFrame from OffscreenCanvas'); + +promise_test(async t => { + let init = { + format: 'I420', + timestamp: 1234, + codedWidth: 4, + codedHeight: 2 + }; + let data = new Uint8Array([ + 1, 2, 3, 4, 5, 6, 7, 8, // y + 1, 2, // u + 1, 2, // v + ]); + let i420Frame = new VideoFrame(data, init); + let validFrame = new VideoFrame(i420Frame); + validFrame.close(); +}, 'Test that timestamp is NOT required when constructing VideoFrame from another VideoFrame'); + +test(t => { + let image = makeImageBitmap(1, 1); + let frame = new VideoFrame(image, {timestamp: 10}); + + assert_equals(frame.visibleRect.width, 1, 'visibleRect.width'); + assert_equals(frame.visibleRect.height, 1, 'visibleRect.height'); + assert_equals(frame.displayWidth, 1, 'displayWidth'); + assert_equals(frame.displayHeight, 1, 'displayHeight'); + + frame.close(); +}, 'Test we can construct an odd-sized VideoFrame.'); + +test(t => { + // Test only valid for Window contexts. + if (!('document' in self)) + return; + + let video = document.createElement('video'); + + assert_throws_dom('InvalidStateError', () => { + let frame = new VideoFrame(video, {timestamp: 10}); + }) +}, 'Test constructing w/ unusable image argument throws: HAVE_NOTHING