From a779f161fe24875e33d3a79d0ab0b1fa63fd0434 Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Wed, 7 Oct 2026 13:34:21 -0700 Subject: [PATCH 1/2] DOC: Disable landing page video autoplay Replace MP4 iframe embeds with linked preview images so walkthroughs open only after an explicit click. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- doc/css/custom.css | 14 -------------- doc/index.md | 18 ++++-------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/doc/css/custom.css b/doc/css/custom.css index 1d71444d4a..953a1be6e3 100644 --- a/doc/css/custom.css +++ b/doc/css/custom.css @@ -178,20 +178,6 @@ html.dark .myst-card > .myst-card-body > img.ecosystem-logo { margin-bottom: 0.5rem; } -/* Landing-page walkthroughs retain lightweight poster images until played. */ -.landing-demo-video { - display: block; - width: 100%; - margin: 1rem 0; - background: #0b0b0b; - border-radius: 6px; - overflow: hidden; -} - -.landing-demo-video > div { - padding-bottom: 56.25% !important; -} - /* --- Roakey sprite animations ---------------------------------------------- Each sprite is a fixed-size window onto a horizontal frame strip. With `object-fit: none` the strip renders at 1:1 and the box clips it to a single diff --git a/doc/index.md b/doc/index.md index 22d4003790..ad9529736e 100644 --- a/doc/index.md +++ b/doc/index.md @@ -133,14 +133,9 @@ Run security assessments from the command line with `pyrit_scan` or the interact pyrit_scan run airt.scam --target openai_chat ``` -```{iframe} https://commandline.microsoft.com/wp-content/uploads/2026/08/scanner_walkthrough.mp4 -:width: 100% -:title: PyRIT Scanner walkthrough -:placeholder: scanner-demo.png -:class: landing-demo-video -``` +[![Play the PyRIT Scanner walkthrough](scanner-demo.png)](assets/videos/scanner-walkthrough.mp4) -[Open the Scanner walkthrough directly](assets/videos/scanner-walkthrough.mp4). +[Watch the Scanner walkthrough](assets/videos/scanner-walkthrough.mp4). Use `pyrit_scan --help` to learn more about what else `pyrit_scan` can do. For more details, see the [Scanner](scanner/0_scanner) page. @@ -154,14 +149,9 @@ Start the local web app and give it a try: ```bash pyrit_backend # serves webapp on http://localhost:8000/ ``` -```{iframe} https://commandline.microsoft.com/wp-content/uploads/2026/08/CoPyRIT-GUI-walkthrough.mp4 -:width: 100% -:title: CoPyRIT GUI walkthrough -:placeholder: copyrit-demo.png -:class: landing-demo-video -``` +[![Play the CoPyRIT GUI walkthrough](copyrit-demo.png)](assets/videos/copyrit-walkthrough.mp4) -[Open the CoPyRIT GUI walkthrough directly](assets/videos/copyrit-walkthrough.mp4). +[Watch the CoPyRIT GUI walkthrough](assets/videos/copyrit-walkthrough.mp4). For more details, see the [GUI](gui/0_gui) page. ::: From c5c3e72cd3de30b627c86863a89c65a1272b753b Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Wed, 7 Oct 2026 22:58:51 -0700 Subject: [PATCH 2/2] DOC: Keep walkthrough videos embedded without autoplay Use local iframe players with manual controls, pause hidden or competing walkthroughs, and publish their assets through MyST. Exclude embedded player pages from the version picker. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- build_scripts/inject_version_picker.py | 4 +- doc/assets/videos/copyrit-player.html | 17 +++ doc/assets/videos/player.css | 14 +++ doc/assets/videos/player.js | 36 ++++++ doc/assets/videos/scanner-player.html | 17 +++ doc/css/custom.css | 13 ++ doc/index.md | 18 ++- doc/myst.yml | 5 + .../test_inject_version_picker.py | 14 +++ .../test_landing_video_players.py | 117 ++++++++++++++++++ 10 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 doc/assets/videos/copyrit-player.html create mode 100644 doc/assets/videos/player.css create mode 100644 doc/assets/videos/player.js create mode 100644 doc/assets/videos/scanner-player.html create mode 100644 tests/unit/build_scripts/test_landing_video_players.py diff --git a/build_scripts/inject_version_picker.py b/build_scripts/inject_version_picker.py index cbfc4852cd..e2d802b791 100644 --- a/build_scripts/inject_version_picker.py +++ b/build_scripts/inject_version_picker.py @@ -12,6 +12,7 @@ * (CSS is bundled inside) Idempotent: files that already contain our marker are skipped. +Embedded players opt out with the ``pyrit-no-version-picker`` HTML comment. Why inline the JS instead of + + + + + diff --git a/doc/assets/videos/player.css b/doc/assets/videos/player.css new file mode 100644 index 0000000000..dde8f1d643 --- /dev/null +++ b/doc/assets/videos/player.css @@ -0,0 +1,14 @@ +html, +body { + width: 100%; + height: 100%; + margin: 0; + background: #0b0b0b; +} + +video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} diff --git a/doc/assets/videos/player.js b/doc/assets/videos/player.js new file mode 100644 index 0000000000..51da77d69a --- /dev/null +++ b/doc/assets/videos/player.js @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +(function () { + "use strict"; + + const video = document.querySelector("video"); + const frame = window.frameElement; + const tabSet = frame?.closest(".myst-tab-set"); + if (!tabSet) return; + + video.addEventListener("play", () => { + if (frame.getClientRects().length === 0) { + video.pause(); + return; + } + for (const sibling of tabSet.querySelectorAll(".landing-demo-video iframe")) { + if (sibling !== frame) { + sibling.contentDocument?.querySelector("video")?.pause(); + } + } + }); + + const observer = new MutationObserver(() => { + if (frame.getClientRects().length === 0) video.pause(); + }); + function observeTabSet() { + observer.observe(tabSet, { + attributes: true, + attributeFilter: ["class", "hidden", "style"], + subtree: true, + }); + } + observeTabSet(); + window.addEventListener("pagehide", () => observer.disconnect()); + window.addEventListener("pageshow", observeTabSet); +})(); diff --git a/doc/assets/videos/scanner-player.html b/doc/assets/videos/scanner-player.html new file mode 100644 index 0000000000..f870bfbda1 --- /dev/null +++ b/doc/assets/videos/scanner-player.html @@ -0,0 +1,17 @@ + + + + + + + PyRIT Scanner walkthrough + + + + + + + diff --git a/doc/css/custom.css b/doc/css/custom.css index 953a1be6e3..aa74720dfc 100644 --- a/doc/css/custom.css +++ b/doc/css/custom.css @@ -178,6 +178,19 @@ html.dark .myst-card > .myst-card-body > img.ecosystem-logo { margin-bottom: 0.5rem; } +.landing-demo-video { + display: block; + width: 100%; + margin: 1rem 0; + background: #0b0b0b; + border-radius: 6px; + overflow: hidden; +} + +.landing-demo-video > div { + padding-bottom: 56.25% !important; +} + /* --- Roakey sprite animations ---------------------------------------------- Each sprite is a fixed-size window onto a horizontal frame strip. With `object-fit: none` the strip renders at 1:1 and the box clips it to a single diff --git a/doc/index.md b/doc/index.md index ad9529736e..cdc898e3ce 100644 --- a/doc/index.md +++ b/doc/index.md @@ -133,9 +133,14 @@ Run security assessments from the command line with `pyrit_scan` or the interact pyrit_scan run airt.scam --target openai_chat ``` -[![Play the PyRIT Scanner walkthrough](scanner-demo.png)](assets/videos/scanner-walkthrough.mp4) +```{iframe} videos/scanner-player.html +:width: 100% +:title: PyRIT Scanner walkthrough +:placeholder: scanner-demo.png +:class: landing-demo-video +``` -[Watch the Scanner walkthrough](assets/videos/scanner-walkthrough.mp4). +[Open the Scanner walkthrough directly](assets/videos/scanner-walkthrough.mp4). Use `pyrit_scan --help` to learn more about what else `pyrit_scan` can do. For more details, see the [Scanner](scanner/0_scanner) page. @@ -149,9 +154,14 @@ Start the local web app and give it a try: ```bash pyrit_backend # serves webapp on http://localhost:8000/ ``` -[![Play the CoPyRIT GUI walkthrough](copyrit-demo.png)](assets/videos/copyrit-walkthrough.mp4) +```{iframe} videos/copyrit-player.html +:width: 100% +:title: CoPyRIT GUI walkthrough +:placeholder: copyrit-demo.png +:class: landing-demo-video +``` -[Watch the CoPyRIT GUI walkthrough](assets/videos/copyrit-walkthrough.mp4). +[Open the CoPyRIT GUI walkthrough directly](assets/videos/copyrit-walkthrough.mp4). For more details, see the [GUI](gui/0_gui) page. ::: diff --git a/doc/myst.yml b/doc/myst.yml index 7e8a8c59f8..6a8754afc7 100644 --- a/doc/myst.yml +++ b/doc/myst.yml @@ -11,6 +11,11 @@ project: - format: pdf template: plain_latex_book output: exports/book.pdf + # MyST copies these to the site root, preserving the videos directory. + static_files: + - assets/videos + - scanner-demo.png + - copyrit-demo.png # See https://mystmd.org/guide for error_rules schema. # Rule IDs come from https://github.com/jupyter-book/mystmd # (packages/myst-common/src/ruleids.ts). diff --git a/tests/unit/build_scripts/test_inject_version_picker.py b/tests/unit/build_scripts/test_inject_version_picker.py index d760684889..4de04609b2 100644 --- a/tests/unit/build_scripts/test_inject_version_picker.py +++ b/tests/unit/build_scripts/test_inject_version_picker.py @@ -7,9 +7,13 @@ import importlib.util import sys from pathlib import Path +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + from types import ModuleType + REPO_ROOT = Path(__file__).resolve().parents[3] SCRIPT = REPO_ROOT / "build_scripts" / "inject_version_picker.py" @@ -99,3 +103,13 @@ def test_handles_html_without_head(injector_module, tmp_path): out = (site / "fragment.html").read_text(encoding="utf-8") assert '' in out assert "pyrit-version-picker" in out + + +def test_embedded_player_opts_out(*, injector_module: ModuleType, tmp_path: Path) -> None: + html = ( + "\n\n" + "" + ) + site = _build_site(tmp_path, html) + assert injector_module.main(["--site-dir", str(site), "--base", "/PyRIT/latest"]) == 0 + assert (site / "index.html").read_text(encoding="utf-8") == html diff --git a/tests/unit/build_scripts/test_landing_video_players.py b/tests/unit/build_scripts/test_landing_video_players.py new file mode 100644 index 0000000000..cb69fb5170 --- /dev/null +++ b/tests/unit/build_scripts/test_landing_video_players.py @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. +"""Regression tests for the landing page's embedded walkthroughs.""" + +import re +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +DOC_ROOT = Path(__file__).resolve().parents[3] / "doc" + + +@pytest.mark.parametrize("name", ["scanner", "copyrit"]) +def test_player_requires_manual_playback(name: str) -> None: + html = (DOC_ROOT / "assets" / "videos" / f"{name}-player.html").read_text(encoding="utf-8") + video = re.search(r"]*)>", html) + assert video is not None + attributes = dict(re.findall(r'([\w-]+)(?:="([^"]*)")?', video.group(1))) + assert "controls" in attributes + assert "playsinline" in attributes + assert attributes["preload"] == "none" + assert not {"autoplay", "loop", "muted"} & attributes.keys() + assert attributes["src"] == f"{name}-walkthrough.mp4" + assert attributes["poster"] == f"../{name}-demo.png" + assert "" in html + + +def test_player_assets_are_published() -> None: + config = yaml.safe_load((DOC_ROOT / "myst.yml").read_text(encoding="utf-8")) + assert {"assets/videos", "scanner-demo.png", "copyrit-demo.png"} <= set(config["project"]["static_files"]) + for name in ["scanner", "copyrit"]: + assert (DOC_ROOT / "assets" / "videos" / f"{name}-walkthrough.mp4").is_file() + assert (DOC_ROOT / f"{name}-demo.png").is_file() + assert f"{{iframe}} videos/{name}-player.html" in (DOC_ROOT / "index.md").read_text(encoding="utf-8") + for asset in ["player.js", "player.css"]: + assert (DOC_ROOT / "assets" / "videos" / asset).is_file() + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not installed") +@pytest.mark.parametrize("scenario", ["play", "hidden_play", "hide", "visible", "standalone", "pagehide", "restore"]) +def test_player_coordinates_playback(scenario: str) -> None: + program = """ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const vm = require("node:vm"); +const scenario = process.argv[2]; +const events = {}; +const windowEvents = {}; +const video = { + paused: false, + pause() { this.paused = true; }, + addEventListener(name, callback) { events[name] = callback; }, +}; +const siblingVideo = { paused: false, pause() { this.paused = true; } }; +const sibling = { contentDocument: { querySelector: () => siblingVideo } }; +const unloaded = { contentDocument: null }; +let visible = true; +const tabSet = { querySelectorAll: () => [frame, sibling, unloaded] }; +const frame = { + closest: () => tabSet, + getClientRects: () => visible ? [{}] : [], +}; +let observer; +class MutationObserver { + constructor(callback) { this.callback = callback; observer = this; } + observe(target, options) { + assert.equal(target, tabSet); + assert.deepEqual(Array.from(options.attributeFilter), ["class", "hidden", "style"]); + assert.equal(options.subtree, true); + this.disconnected = false; + } + disconnect() { this.disconnected = true; } +} +vm.runInNewContext(fs.readFileSync(process.argv[1], "utf8"), { + document: { querySelector: () => video }, + window: { + frameElement: scenario === "standalone" ? null : frame, + addEventListener(name, callback) { windowEvents[name] = callback; }, + }, + MutationObserver, +}); +assert.equal(video.paused, false); +assert.equal(siblingVideo.paused, false); +if (scenario === "standalone") { + assert.equal(observer, undefined); +} else if (scenario === "pagehide") { + windowEvents.pagehide(); + assert.equal(observer.disconnected, true); +} else if (scenario === "restore") { + windowEvents.pagehide(); + windowEvents.pageshow(); + assert.equal(observer.disconnected, false); + visible = false; + observer.callback(); + assert.equal(video.paused, true); +} else if (scenario === "play" || scenario === "hidden_play") { + visible = scenario === "play"; + events.play(); + assert.equal(video.paused, !visible); + assert.equal(siblingVideo.paused, visible); +} else { + visible = scenario === "visible"; + observer.callback(); + assert.equal(video.paused, !visible); + assert.equal(siblingVideo.paused, false); +} +""" + result = subprocess.run( + ["node", "-e", program, str(DOC_ROOT / "assets" / "videos" / "player.js"), scenario], + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr