Source code
Revision control
Copy as Markdown
Other Tools
Test Info:
- This WPT test may be referenced by the following Test IDs:
            - /mediacapture-streams/MediaStreamTrack-resizeMode.https.html - WPT Dashboard Interop Dashboard
 
<!doctype html>
<title>MediaStreamTrack video resizeMode. 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>
  "use strict"
  async function test_framerate_between_exclusive(t, track, lower, upper) {
    const video = document.createElement("video");
    document.body.appendChild(video);
    t.add_cleanup(async () => document.body.removeChild(video));
    video.srcObject = new MediaStream([track]);
    await video.play();
    const numSeconds = 2;
    await new Promise(r => setTimeout(r, numSeconds * 1000));
    const totalVideoFrames = video.mozPaintedFrames;
    assert_between_exclusive(totalVideoFrames / numSeconds, lower, upper, "totalVideoFrames");
  }
  function createSettingsDicts(width, height, step = 1) {
    const settingsDicts = [], aspect = width / height;
    do {
      settingsDicts.push({ width, height });
      if (width > height) {
        height = Math.round((width - step) / aspect);
        width -= step;
      } else {
        width = Math.round((height - step) * aspect);
        height -= step;
      }
    } while (width > 2 && height > 2);
    return settingsDicts;
  }
  function integerFitness(actual, ideal) {
    if (actual == ideal) {
      return 0;
    }
    return Math.abs(actual - ideal) / Math.max(Math.abs(actual), Math.abs(ideal));
  }
  function findFittestResolutionSetting(width, height, constraints) {
    const widthIsNumber = typeof constraints.width == "number";
    const heightIsNumber = typeof constraints.height == "number";
    const c = {
      width: {
        ideal: widthIsNumber ? constraints.width : constraints?.width?.ideal,
        max: constraints?.width?.max ?? 1000000,
      },
      height: {
        ideal: heightIsNumber ? constraints.height : constraints?.height?.ideal,
        max: constraints?.height?.max ?? 1000000,
      },
    };
    const dicts = createSettingsDicts(width, height)
      .filter(s => s.width <= c.width.max && s.height <= c.height.max);
    for (const dict of dicts) {
      dict.distance =
        integerFitness(dict.width, c.width.ideal) +
        integerFitness(dict.height, c.height.ideal);
    }
    const filteredDicts = dicts.filter(s => {
      return (!c.width.ideal || s.width <= c.width.ideal) &&
             (!c.height.ideal || s.height <= c.height.ideal);
    });
    return filteredDicts.reduce(
      (a, b) => (a.distance < b.distance ? a : b),
      filteredDicts[0],
    );
  }
  // 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: 500}, nativeLow],
    [{resizeMode: "none", height: 500}, nativeLow],
    [{resizeMode: "none", width: 500, height: 500}, nativeLow],
    [{resizeMode: "none", frameRate: 50}, nativeLow],
    [{resizeMode: "none", width: 500, height: 500, frameRate: 50}, nativeLow],
    [{resizeMode: "none", width: 1000}, nativeHigh],
    [{resizeMode: "none", height: 1000}, nativeHigh],
    [{resizeMode: "none", width: 1000, height: 1000}, nativeHigh],
    [{resizeMode: "none", frameRate: 1}, nativeHigh, [3, 12]],
    [{resizeMode: "none", width: 1000, height: 1000, frameRate: 1}, nativeHigh],
    [
      {resizeMode: "crop-and-scale"},
      {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", height: 500},
      {resizeMode: "crop-and-scale", width: 889, height: 500, frameRate: 10}
    ],
    [
      {resizeMode: "crop-and-scale", width: {min: 500}, height: {max: 200}},
      {resizeMode: "crop-and-scale", width: 500, height: 200, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", frameRate: 50},
      {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", width: 10000, frameRate: {min: 30}},
      {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", frameRate: {exact: 5}},
      {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 5},
      [2, 7]
    ],
  ].forEach(([video, expected, testFramerate]) => promise_test(async t => {
      const stream = await navigator.mediaDevices.getUserMedia({video});
      const [track] = stream.getTracks();
      t.add_cleanup(() => track.stop());
      const settings = track.getSettings();
      for (const key of Object.keys(expected)) {
        assert_equals(settings[key], expected[key], key);
      }
      if (testFramerate) {
        const [low, high] = testFramerate;
        await test_framerate_between_exclusive(t, track, low, high);
      }
    }, `gUM gets ${JSON.stringify(expected)} mode by ${JSON.stringify(video)}`));
  promise_test(async t => {
    const stream = await navigator.mediaDevices.getUserMedia({video: {resizeMode: "none", height: 720}});
    const [track] = stream.getTracks();
    const stream2 = await navigator.mediaDevices.getUserMedia({video: {resizeMode: "crop-and-scale", height: 300}});
    const [track2] = stream2.getTracks();
    t.add_cleanup(() => {
      track.stop();
      track2.stop();
    });
    const settings = track.getSettings();
    assert_equals(settings.resizeMode, "none", "track resizeMode");
    assert_equals(settings.width, 1280, "track width");
    assert_equals(settings.height, 720, "track height");
    assert_equals(settings.frameRate, 10, "track framerate");
    const settings2 = track2.getSettings();
    assert_equals(settings2.resizeMode, "crop-and-scale", "track2 resizeMode");
    assert_equals(settings2.width, 400, "track2 width");
    assert_equals(settings2.height, 300, "track2 height");
    // masquerade the selected capability.
    assert_equals(settings2.frameRate, 30, "track2 framerate");
  }, `gUM gets expected downscaling with competing capabilities`);
  promise_test(async t => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia(
          {video: {resizeMode: "none", width: {min: 2000}}}
      );
      const [track] = stream.getTracks();
      t.add_cleanup(() => track.stop());
    } catch(e) {
      assert_equals(e.name, "OverconstrainedError", `got error ${e.message}`);
      return;
    }
    assert_unreached("gUM is rejected with impossible width");
  }, "gUM is rejected by resizeMode none and impossible min-width");
  promise_test(async t => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia(
          {video: {resizeMode: "none", width: {max: 200}}}
      );
      const [track] = stream.getTracks();
      t.add_cleanup(() => track.stop());
    } catch(e) {
      assert_equals(e.name, "OverconstrainedError", `got error ${e.message}`);
      return;
    }
    assert_unreached("gUM is rejected with impossible width");
  }, "gUM is rejected by resizeMode none and impossible max-width");
  promise_test(async t => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia(
          {video: {resizeMode: "crop-and-scale", width: {min: 2000}}}
      );
      const [track] = stream.getTracks();
      t.add_cleanup(() => track.stop());
    } catch(e) {
      assert_equals(e.name, "OverconstrainedError", `got error ${e.message}`);
      return;
    }
    assert_unreached("gUM is rejected with impossible width");
  }, "gUM is rejected by resizeMode crop-and-scale and impossible width");
  promise_test(async t => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia(
          {video: {resizeMode: "crop-and-scale", frameRate: {min: 50}}}
      );
      const [track] = stream.getTracks();
      t.add_cleanup(() => track.stop());
    } catch(e) {
      assert_equals(e.name, "OverconstrainedError", `got error ${e.message}`);
      return;
    }
    assert_unreached("gUM is rejected with impossible fps");
  }, "gUM is rejected by resizeMode crop-and-scale and impossible fps");
  [
    [{resizeMode: "none", width: 500}, nativeLow],
    [{resizeMode: "none", height: 500}, nativeLow],
    [{resizeMode: "none", width: 500, height: 500}, nativeLow],
    [{resizeMode: "none", frameRate: 50}, nativeLow],
    [{resizeMode: "none", width: 500, height: 500, frameRate: 50}, nativeLow],
    [{resizeMode: "none", width: 1000}, nativeHigh],
    [{resizeMode: "none", height: 1000}, nativeHigh],
    [{resizeMode: "none", width: 1000, height: 1000}, nativeHigh],
    [{resizeMode: "none", frameRate: 1}, nativeHigh, [3, 12]],
    [{resizeMode: "none", width: 1000, height: 1000, frameRate: 1}, nativeHigh],
    [
      {resizeMode: "crop-and-scale"},
      {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", height: 400},
      {resizeMode: "crop-and-scale", width: 533, height: 400, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", height: 500},
      {resizeMode: "crop-and-scale", width: 889, height: 500, frameRate: 10}
    ],
    [
      {resizeMode: "crop-and-scale", height: {exact: 500}},
      {resizeMode: "crop-and-scale", width: 889, height: 500, frameRate: 10}
    ],
    [
      {resizeMode: "crop-and-scale", width: {min: 500}, height: {max: 200}},
      {resizeMode: "crop-and-scale", width: 500, height: 200, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", frameRate: 50},
      {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", width: 10000, frameRate: {min: 30}},
      {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 30}
    ],
    [
      {resizeMode: "crop-and-scale", frameRate: {exact: 5}},
      {resizeMode: "crop-and-scale", width: 640, height: 480, frameRate: 5},
      [2, 7]
    ],
  ].forEach(([video, expected, testFramerate]) => promise_test(async t => {
      const stream = await navigator.mediaDevices.getUserMedia({video: true});
      const [track] = stream.getTracks();
      t.add_cleanup(() => track.stop());
      await track.applyConstraints(video);
      const settings = track.getSettings();
      for (const key of Object.keys(expected)) {
        assert_equals(settings[key], expected[key], key);
      }
      if (testFramerate) {
        const [low, high] = testFramerate;
        await test_framerate_between_exclusive(t, track, low, high);
      }
    }, `applyConstraints gets ${JSON.stringify(expected)} mode by ${JSON.stringify(video)}`));
  promise_test(async t => {
    const stream = await navigator.mediaDevices.getUserMedia({video: true});
    const [track] = stream.getTracks();
    t.add_cleanup(() => track.stop());
    try {
      await track.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 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();
    t.add_cleanup(() => track.stop());
    try {
      await track.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 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();
    t.add_cleanup(() => track.stop());
    try {
      await track.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 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();
    t.add_cleanup(() => track.stop());
    try {
      await track.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 is rejected by resizeMode crop-and-scale impossible fps");
  // 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() {
    // return screenWidth() / screenPixelRatio();
    return screenWidth();
  }
  function desktopHeight() {
    // return screenHeight() / screenPixelRatio();
    return screenHeight();
  }
  promise_test(async t => {
    await test_driver.bless('getDisplayMedia()');
    const stream = await navigator.mediaDevices.getDisplayMedia(
        {video: {resizeMode: "none", width: 100}}
    );
    const [track] = stream.getTracks();
    t.add_cleanup(() => track.stop());
    assert_equals(track.getSettings().width, screenWidth(), "width");
    assert_equals(track.getSettings().height, screenHeight(), "height");
    assert_equals(track.getSettings().frameRate, 60, "framerate");
    assert_equals(track.getSettings().resizeMode, "none", "resizeMode");
  }, "gDM gets full screen resolution by width");
  function defaultScreen() {
    return {
      resizeMode: "crop-and-scale",
      width: screenWidth(),
      height: screenHeight(),
      frameRate: 30,
    };
  }
  function nativeScreen() {
    return {
      resizeMode: "none",
      width: screenWidth(),
      height: screenHeight(),
      frameRate: 60
    };
  }
  [
    [{resizeMode: "none", width: 100}, nativeScreen],
    [{resizeMode: "none", frameRate: 50}, nativeScreen],
    [{resizeMode: "crop-and-scale"}, defaultScreen],
    [{resizeMode: "crop-and-scale", height: 100}, () => ({
        resizeMode: "crop-and-scale",
        width: Math.round(screenWidth() / screenHeight() * 100),
        height: 100,
        frameRate: 30
      })],
    [{resizeMode: "crop-and-scale", frameRate: 5}, () => {
        const { width, height } = defaultScreen();
        return { width, height, frameRate: 5};
      }, [2, 7]],
    [{resizeMode: "crop-and-scale", frameRate: 50}, () => {
        const { width, height } = defaultScreen();
        return { width, height, frameRate: 50};
      }],
    [{resizeMode: "crop-and-scale", frameRate: 5000}, () => {
        const { width, height } = defaultScreen();
        return { width, height, frameRate: 120};
      }],
  ].forEach(([video, expectedFunc, testFramerate]) => {
      let expected;
      promise_test(async t => {
          expected = expectedFunc();
          await test_driver.bless('getDisplayMedia()');
          const stream = await navigator.mediaDevices.getDisplayMedia({video});
          const [track] = stream.getTracks();
          t.add_cleanup(() => track.stop());
          const settings = track.getSettings();
          for (const key of Object.keys(expected)) {
            assert_equals(settings[key], expected[key], key);
          }
          if (testFramerate) {
            const [low, high] = testFramerate;
            await test_framerate_between_exclusive(t, track, low, high);
          }
        }, `gDM gets expected mode by ${JSON.stringify(video)}`);
    });
  promise_test(async t => {
    await test_driver.bless('getDisplayMedia()');
    const stream = await navigator.mediaDevices.getDisplayMedia(
        {video: {
            resizeMode: "crop-and-scale",
            width: 400,
            height: 400
        }}
    );
    const [track] = stream.getTracks();
    t.add_cleanup(() => track.stop());
    const expected = findFittestResolutionSetting(
      screenWidth(),
      screenHeight(),
      track.getConstraints()
    );
    assert_equals(track.getSettings().width, expected.width, "width");
    assert_equals(track.getSettings().height, expected.height, "height");
    assert_approx_equals(
      track.getSettings().width / track.getSettings().height,
      desktopWidth() / desktopHeight(),
      0.01,
      "aspect ratio"
    );
    assert_equals(track.getSettings().resizeMode, "crop-and-scale", "resizeMode");
  }, "gDM doesn't crop with only ideal dimensions");
  promise_test(async t => {
    await test_driver.bless('getDisplayMedia()');
    const stream = await navigator.mediaDevices.getDisplayMedia(
        {video: {
            resizeMode: "crop-and-scale",
            width: {max: 400},
            height: {ideal: 400}
        }}
    );
    const [track] = stream.getTracks();
    t.add_cleanup(() => track.stop());
    assert_equals(track.getSettings().width, 400, "width");
    assert_equals(
      track.getSettings().height,
      Math.round(screenHeight() / screenWidth() * 400),
      "height"
    );
    assert_equals(track.getSettings().resizeMode, "crop-and-scale", "resizeMode");
  }, "gDM doesn't crop with ideal and max dimensions");
</script>