commit 6d15e2d2f0f1ba847b8737ab3fd74a52def316e7
parent b5d1a06a6d144d0274b08fea918527668bbb46d7
Author: Andreas Pehrson <apehrson@mozilla.com>
Date: Tue, 11 Nov 2025 08:20:24 +0000
Bug 1771789 - Test video capture cloning independence. r=jib
Differential Revision: https://phabricator.services.mozilla.com/D266398
Diffstat:
5 files changed, 421 insertions(+), 0 deletions(-)
diff --git a/testing/web-platform/mozilla/meta/mediacapture-streams/MediaStreamTrack-independent-clones.https.html.ini b/testing/web-platform/mozilla/meta/mediacapture-streams/MediaStreamTrack-independent-clones.https.html.ini
@@ -0,0 +1,2 @@
+[MediaStreamTrack-independent-clones.https.html]
+ prefs: [media.getusermedia.camera.fake.force:true, media.navigator.streams.fake:false]
diff --git a/testing/web-platform/mozilla/meta/screen-capture/MediaStreamTrack-independent-clones.https.html.ini b/testing/web-platform/mozilla/meta/screen-capture/MediaStreamTrack-independent-clones.https.html.ini
@@ -0,0 +1,3 @@
+[MediaStreamTrack-independent-clones.https.html]
+ expected:
+ if os == "android": ERROR
diff --git a/testing/web-platform/mozilla/tests/mediacapture-streams/MediaStreamTrack-independent-clones.https.html b/testing/web-platform/mozilla/tests/mediacapture-streams/MediaStreamTrack-independent-clones.https.html
@@ -0,0 +1,162 @@
+<!doctype html>
+<title>MediaStreamTrack clones have independent settings. Assumes Mozilla's fake camera source with 480p and 720p capabilities.</title>
+<meta name="timeout" content="long">
+<p class="instructions">When prompted, accept to share your video stream.</p>
+<script src=/resources/testharness.js></script>
+<script src=/resources/testharnessreport.js></script>
+<script src=/resources/testdriver.js></script>
+<script src=/resources/testdriver-vendor.js></script>
+<script src=video-test-helper.js></script>
+<script>
+ "use strict"
+
+ // Native capabilities supported by the fake camera.
+ const nativeLow = {width: 640, height: 480, frameRate: 30, resizeMode: "none"};
+ const nativeHigh = {width: 1280, height: 720, frameRate: 10, resizeMode: "none"};
+
+ [
+ [
+ [{resizeMode: "none", width: 1000}, {resizeMode: "none", width: 500}],
+ [nativeHigh, nativeLow],
+ ],
+ [
+ [{resizeMode: "none", height: 500}, {resizeMode: "none", width: 1000}],
+ [nativeLow, nativeHigh],
+ ],
+ [
+ [{resizeMode: "none", width: 500, height: 500}, {resizeMode: "crop-and-scale", width: 1000, height: 750}],
+ [nativeLow, {resizeMode: "crop-and-scale", width: 1000, height: 720, frameRate: 10}],
+ ],
+ [
+ [{resizeMode: "crop-and-scale", width: 800, height: 600}, {resizeMode: "none"}],
+ [{resizeMode: "crop-and-scale", width: 800, height: 600, frameRate: 10}, nativeLow],
+ ],
+ [
+ [{resizeMode: "crop-and-scale", height: 500}, {resizeMode: "crop-and-scale", height: 300}],
+ [
+ {resizeMode: "crop-and-scale", width: 889, height: 500, frameRate: 10},
+ {resizeMode: "crop-and-scale", width: 400, height: 300, frameRate: 30}
+ ],
+ ],
+ [
+ [
+ {resizeMode: "crop-and-scale", frameRate: {exact: 5}},
+ {resizeMode: "crop-and-scale", frameRate: {exact: 1}},
+ ],
+ [
+ {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 5},
+ {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 1},
+ ],
+ [[2, 7], [0.5, 3]],
+ ],
+ ].forEach(
+ ([
+ [video, cloneVideo],
+ [expected, cloneExpected],
+ [testFramerate, cloneTestFramerate] = [null, null]
+ ]) => promise_test(async t => {
+ const stream = await navigator.mediaDevices.getUserMedia({video});
+ const [track] = stream.getTracks();
+ const clone = track.clone();
+ t.add_cleanup(() => {
+ track.stop();
+ clone.stop();
+ });
+ let settings = track.getSettings();
+ let cloneSettings = clone.getSettings();
+ for (const key of Object.keys(expected)) {
+ assert_equals(settings[key], expected[key], `original: ${key}`);
+ assert_equals(cloneSettings[key], expected[key], `clone: ${key}`);
+ }
+ await clone.applyConstraints(cloneVideo);
+ settings = track.getSettings();
+ cloneSettings = clone.getSettings();
+ for (const key of Object.keys(expected)) {
+ assert_equals(settings[key], expected[key], `original-post: ${key}`);
+ }
+ for (const key of Object.keys(cloneExpected)) {
+ assert_equals(cloneSettings[key], cloneExpected[key], `clone-post: ${key}`);
+ }
+ await test_resolution_equals(t, track, settings.width, settings.height);
+ await test_resolution_equals(t, clone, cloneSettings.width, cloneSettings.height);
+ if (testFramerate) {
+ const [low, high] = testFramerate;
+ await test_framerate_between_exclusive(t, track, low, high);
+ }
+ if (cloneTestFramerate) {
+ const [low, high] = cloneTestFramerate;
+ await test_framerate_between_exclusive(t, clone, low, high);
+ }
+ }, `gUM gets ${JSON.stringify(expected)} + clone ` +
+ `${JSON.stringify(cloneExpected)} by ${JSON.stringify(video)} ` +
+ `and ${JSON.stringify(cloneVideo)}`));
+
+
+ promise_test(async t => {
+ const stream = await navigator.mediaDevices.getUserMedia({video: true});
+ const [track] = stream.getTracks();
+ const clone = track.clone();
+ t.add_cleanup(() => {
+ track.stop();
+ clone.stop();
+ });
+ try {
+ await clone.applyConstraints({resizeMode: "none", width: {min: 2000}})
+ } catch(e) {
+ assert_equals(e.name, "OverconstrainedError", `got error ${e.message}`);
+ return;
+ }
+ assert_unreached("applyConstraints is rejected with impossible width");
+ }, "applyConstraints on gUM clone is rejected by resizeMode none and impossible min-width");
+
+ promise_test(async t => {
+ const stream = await navigator.mediaDevices.getUserMedia({video: true});
+ const [track] = stream.getTracks();
+ const clone = track.clone();
+ t.add_cleanup(() => {
+ track.stop();
+ clone.stop();
+ });
+ try {
+ await clone.applyConstraints({resizeMode: "none", width: {max: 200}})
+ } catch(e) {
+ assert_equals(e.name, "OverconstrainedError", `got error ${e.message}`);
+ return;
+ }
+ assert_unreached("applyConstraints is rejected with impossible width");
+ }, "applyConstraints on gUM clone is rejected by resizeMode none and impossible max-width");
+
+ promise_test(async t => {
+ const stream = await navigator.mediaDevices.getUserMedia({video: true});
+ const [track] = stream.getTracks();
+ const clone = track.clone();
+ t.add_cleanup(() => {
+ track.stop();
+ clone.stop();
+ });
+ try {
+ await clone.applyConstraints({resizeMode: "crop-and-scale", width: {min: 2000}})
+ } catch(e) {
+ assert_equals(e.name, "OverconstrainedError", `got error ${e.message}`);
+ return;
+ }
+ assert_unreached("applyConstraints is rejected with impossible width");
+ }, "applyConstraints on gUM clone is rejected by resizeMode crop-and-scale and impossible width");
+
+ promise_test(async t => {
+ const stream = await navigator.mediaDevices.getUserMedia({video: true});
+ const [track] = stream.getTracks();
+ const clone = track.clone();
+ t.add_cleanup(() => {
+ track.stop();
+ clone.stop();
+ });
+ try {
+ await clone.applyConstraints({resizeMode: "crop-and-scale", frameRate: {min: 50}});
+ } catch(e) {
+ assert_equals(e.name, "OverconstrainedError", `got error ${e.message}`);
+ return;
+ }
+ assert_unreached("applyConstraints is rejected with impossible fps");
+ }, "applyConstraints on gUM clone is rejected by resizeMode crop-and-scale impossible fps");
+</script>
diff --git a/testing/web-platform/mozilla/tests/mediacapture-streams/video-test-helper.js b/testing/web-platform/mozilla/tests/mediacapture-streams/video-test-helper.js
@@ -1,5 +1,27 @@
// Helper functions checking video flow using HTMLVideoElement.
+async function test_resolution_equals(t, track, width, height) {
+ const video = document.createElement("video");
+ const timeout = new Promise(r => t.step_timeout(r, 1000));
+ const waitForResize = () => Promise.race([
+ new Promise(r => video.onresize = r),
+ timeout.then(() => { throw new Error("Timeout waiting for resize"); }),
+ ]);
+ video.srcObject = new MediaStream([track]);
+ video.play();
+ // Wait for the first frame.
+ await waitForResize();
+ // There's a potential race with applyConstraints, where a frame of the old
+ // resolution is in flight and renders after applyConstraints has resolved.
+ // In that case, wait for another frame.
+ if (video.videoWidth != width || video.videoHeight != height) {
+ await waitForResize();
+ }
+
+ assert_equals(video.videoWidth, width, "videoWidth");
+ assert_equals(video.videoHeight, height, "videoHeight");
+}
+
async function test_framerate_between_exclusive(t, track, lower, upper) {
const video = document.createElement("video");
document.body.appendChild(video);
diff --git a/testing/web-platform/mozilla/tests/screen-capture/MediaStreamTrack-independent-clones.https.html b/testing/web-platform/mozilla/tests/screen-capture/MediaStreamTrack-independent-clones.https.html
@@ -0,0 +1,232 @@
+<!doctype html>
+<title>MediaStreamTrack clones have independent settings. Assumes Mozilla's fake camera source with 480p and 720p capabilities.</title>
+<meta name="timeout" content="long">
+<p class="instructions">When prompted, accept to share your video stream.</p>
+<script src=/resources/testharness.js></script>
+<script src=/resources/testharnessreport.js></script>
+<script src=/resources/testdriver.js></script>
+<script src=/resources/testdriver-vendor.js></script>
+<script src=../mediacapture-streams/settings-helper.js></script>
+<script src=../mediacapture-streams/video-test-helper.js></script>
+<script>
+ "use strict"
+
+ setup(() => assert_implements("getDisplayMedia" in navigator.mediaDevices, "getDisplayMedia() not supported"));
+
+ // Note these gDM tests will fail if our own window is on a screen different
+ // than the system's first screen. They're functions in case the browser
+ // window needs to be moved to the first screen during the test in order to
+ // pass.
+ function screenPixelRatio() { return SpecialPowers.wrap(window).desktopToDeviceScale; }
+ function screenWidth() { return window.screen.width * window.devicePixelRatio; }
+ function screenHeight() { return window.screen.height * window.devicePixelRatio; }
+ function desktopWidth() {
+ // TODO: Bug 1965499 - scale down by screenPixelRatio by default in resizeMode: crop-and-scale.
+ // return screenWidth() / screenPixelRatio();
+ return screenWidth();
+ }
+ function desktopHeight() {
+ // TODO: Bug 1965499 - scale down by screenPixelRatio by default in resizeMode: crop-and-scale.
+ // return screenHeight() / screenPixelRatio();
+ return screenHeight();
+ }
+
+ // TODO: By default we shouldn't be multiplying with window.devicePixelRatio (bug 1703991).
+ function defaultScreen() {
+ return {
+ resizeMode: "crop-and-scale",
+ width: screenWidth(),
+ height: screenHeight(),
+ frameRate: 30,
+ };
+ }
+ // TODO: Should get the source's real refresh rate for frameRate (bug 1984363).
+ function nativeScreen() {
+ return {
+ resizeMode: "none",
+ width: screenWidth(),
+ height: screenHeight(),
+ frameRate: 60
+ };
+ }
+
+ [
+ [
+ [{resizeMode: "none", width: 100}, {resizeMode: "none", height: 500}],
+ [nativeScreen, nativeScreen],
+ ],
+ [
+ [{resizeMode: "none", frameRate: 50}, {resizeMode: "none", frameRate: 1}],
+ [nativeScreen, nativeScreen],
+ ],
+ [
+ [{resizeMode: "none"}, {resizeMode: "crop-and-scale"}],
+ [nativeScreen, defaultScreen],
+ ],
+ [
+ [{resizeMode: "crop-and-scale"}, {resizeMode: "crop-and-scale", height: 100}],
+ [
+ defaultScreen,
+ () => ({
+ resizeMode: "crop-and-scale",
+ width: Math.round(screenWidth() / screenHeight() * 100),
+ height: 100,
+ frameRate: 30
+ }),
+ ],
+ ],
+ [
+ [{resizeMode: "crop-and-scale", height: 100}, {resizeMode: "crop-and-scale"}],
+ [
+ () => ({
+ resizeMode: "crop-and-scale",
+ width: Math.round(screenWidth() / screenHeight() * 100),
+ height: 100,
+ frameRate: 30
+ }),
+ defaultScreen,
+ ],
+ ],
+ [
+ [{resizeMode: "crop-and-scale", frameRate: 5}, {resizeMode: "crop-and-scale", frameRate: 50}],
+ [
+ () => {
+ const { width, height } = defaultScreen();
+ return { width, height, frameRate: 5};
+ },
+ () => {
+ const { width, height } = defaultScreen();
+ return { width, height, frameRate: 50};
+ },
+ ],
+ [[2, 7], [2, 50]],
+ ],
+ [
+ [{resizeMode: "crop-and-scale", frameRate: 50}, {resizeMode: "crop-and-scale", frameRate: 5}],
+ [
+ () => {
+ const { width, height } = defaultScreen();
+ return { width, height, frameRate: 50};
+ },
+ () => {
+ const { width, height } = defaultScreen();
+ return { width, height, frameRate: 5};
+ },
+ [[2, 50], [2, 7]]
+ ],
+ ],
+ [
+ [{resizeMode: "none"}, {resizeMode: "crop-and-scale", frameRate: 5000}, {}],
+ [
+ nativeScreen,
+ () => {
+ const { width, height } = defaultScreen();
+ return { width, height, frameRate: 120};
+ },
+ ]
+ ],
+ ].forEach(
+ ([
+ [video, cloneVideo],
+ [expectedFunc, cloneExpectedFunc],
+ [testFramerate, cloneTestFramerate] = [null, null]
+ ]) => {
+ let expected, cloneExpected;
+ promise_test(async t => {
+ expected = expectedFunc();
+ cloneExpected = cloneExpectedFunc();
+ await test_driver.bless('getDisplayMedia()');
+ const stream = await navigator.mediaDevices.getDisplayMedia({video});
+ const [track] = stream.getTracks();
+ const clone = track.clone();
+ t.add_cleanup(() => {
+ track.stop();
+ clone.stop();
+ });
+ let settings = track.getSettings();
+ let cloneSettings = clone.getSettings();
+ for (const key of Object.keys(expected)) {
+ assert_equals(settings[key], expected[key], `original: ${key}`);
+ assert_equals(cloneSettings[key], expected[key], `clone: ${key}`);
+ }
+ await clone.applyConstraints(cloneVideo);
+ settings = track.getSettings();
+ cloneSettings = clone.getSettings();
+ for (const key of Object.keys(expected)) {
+ assert_equals(settings[key], expected[key], `original-post: ${key}`);
+ }
+ for (const key of Object.keys(cloneExpected)) {
+ assert_equals(cloneSettings[key], cloneExpected[key], `clone-post: ${key}`);
+ }
+ await test_resolution_equals(t, track, settings.width, settings.height);
+ await test_resolution_equals(t, clone, cloneSettings.width, cloneSettings.height);
+ if (testFramerate) {
+ const [low, high] = testFramerate;
+ await test_framerate_between_exclusive(t, track, low, high);
+ }
+ if (cloneTestFramerate) {
+ const [low, high] = cloneTestFramerate;
+ await test_framerate_between_exclusive(t, clone, low, high);
+ }
+ }, `gDM gets expected modes by ${JSON.stringify(video)} + (cloned) ${JSON.stringify(cloneVideo)}`);
+ });
+
+ promise_test(async t => {
+ await test_driver.bless('getDisplayMedia()');
+ const stream = await navigator.mediaDevices.getDisplayMedia({video: {resizeMode: "none"}});
+ const [track] = stream.getTracks();
+ const clone = track.clone();
+ t.add_cleanup(() => {
+ track.stop();
+ clone.stop();
+ });
+ await clone.applyConstraints(
+ {
+ resizeMode: "crop-and-scale",
+ width: 400,
+ height: 400
+ }
+ );
+ const expected = findFittestResolutionSetting(
+ screenWidth(),
+ screenHeight(),
+ clone.getConstraints()
+ );
+ assert_equals(clone.getSettings().width, expected.width, "width");
+ assert_equals(clone.getSettings().height, expected.height, "height");
+ await test_resolution_equals(t, clone, clone.getSettings().width, clone.getSettings().height);
+ assert_approx_equals(
+ clone.getSettings().width / clone.getSettings().height,
+ desktopWidth() / desktopHeight(),
+ 0.01,
+ "aspect ratio"
+ );
+ assert_equals(clone.getSettings().resizeMode, "crop-and-scale", "resizeMode");
+ }, "applyConstraints on gDM clone doesn't crop with only ideal dimensions");
+
+ promise_test(async t => {
+ await test_driver.bless('getDisplayMedia()');
+ const stream = await navigator.mediaDevices.getDisplayMedia({video: {resizeMode: "none"}});
+ const [track] = stream.getTracks();
+ const clone = track.clone();
+ t.add_cleanup(() => {
+ track.stop();
+ clone.stop();
+ });
+ await clone.applyConstraints(
+ {
+ resizeMode: "crop-and-scale",
+ width: {max: 400},
+ height: {ideal: 400}
+ }
+ );
+ assert_equals(clone.getSettings().width, 400, "width");
+ assert_equals(
+ clone.getSettings().height,
+ Math.round(screenHeight() / screenWidth() * 400),
+ "height"
+ );
+ await test_resolution_equals(t, clone, clone.getSettings().width, clone.getSettings().height);
+ assert_equals(clone.getSettings().resizeMode, "crop-and-scale", "resizeMode");
+ }, "applyConstraints on gDM clone doesn't crop with ideal and max dimensions");
+</script>