diff --git a/docs/specs/DESIGN-SYSTEM.md b/docs/specs/DESIGN-SYSTEM.md index c6634fe9..2cf5196c 100644 --- a/docs/specs/DESIGN-SYSTEM.md +++ b/docs/specs/DESIGN-SYSTEM.md @@ -131,6 +131,7 @@ Screenshots and video of a SharpLsp feature show the real extension, never a moc - **Privacy.** No frame may show a user name, home directory or hostname. The demo runs from a neutral temporary path, and the editor runs on a copy-on-write clone of the .NET install under that path, with its login-shell environment off, so every path it prints is neutral. A terminal appears only where the feature runs in one (F# Interactive, a build task, a contributor's command line), and a shell terminal starts with no profile and a bare `$ ` prompt. - **Formats.** - Films ship as H.264 MP4 and VP9 WebM with a WebP poster. + - Prefer H.264 MP4 before WebM: Windows Chrome/Edge may accept VP9 but fail during decoding, without trying the next source. Playback tests must verify advancing time and decoded frames, pause, seek and resume on installed Windows Chrome and Edge as well as desktop and phone browser projects. - Stills ship as WebP, 2400px wide, with `width` and `height` set. - Marketing pages may autoplay a film only when it is muted and looping; docs pages use controls and are never muted. - Every autoplaying video pauses and shows its controls under `prefers-reduced-motion: reduce`. diff --git a/src/website/playwright.config.js b/src/website/playwright.config.js index 51990848..7ba46606 100644 --- a/src/website/playwright.config.js +++ b/src/website/playwright.config.js @@ -1,5 +1,9 @@ import { defineConfig, devices } from '@playwright/test'; +// [WEB-DESIGN-MEDIA] Windows WebKit rejects known-good H.264. Keep phone viewport/touch coverage +// on installed Chrome there, and WebKit elsewhere, without skipping decoding. +const phoneBrowser = process.platform === 'win32' ? { browserName: 'chromium', channel: 'chrome' } : {}; + export default defineConfig({ testDir: './tests', fullyParallel: true, @@ -12,17 +16,24 @@ export default defineConfig({ trace: 'on-first-retry', }, projects: [ + // The Windows system decoder failed where bundled Chromium/WebKit passed. + // Keep the regression on installed browsers, with their default GPU settings. + ...(process.platform === 'win32' ? ['chrome', 'msedge'].map((channel) => ({ + name: channel, + testMatch: 'video-playback.spec.js', + use: { ...devices['Desktop Chrome'], channel }, + })) : []), { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, { name: 'iPhone SE', - use: { ...devices['iPhone SE'] }, + use: { ...devices['iPhone SE'], ...phoneBrowser }, }, { name: 'iPhone 14', - use: { ...devices['iPhone 14'] }, + use: { ...devices['iPhone 14'], ...phoneBrowser }, }, ], webServer: { diff --git a/src/website/src/_data/videos.json b/src/website/src/_data/videos.json index 5d30bb12..de623972 100644 --- a/src/website/src/_data/videos.json +++ b/src/website/src/_data/videos.json @@ -1,6 +1,6 @@ { "profiler-film": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1080, "duration": "PT1M4S", @@ -63,7 +63,7 @@ } }, "profiler-fix-memory-leak": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M32S", @@ -85,7 +85,7 @@ "captions": ["en", "ja", "zh"] }, "profiler-fsharp": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M52S", @@ -107,7 +107,7 @@ } }, "sharplsp-tour": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1000, "height": 562, "duration": "PT22S", @@ -128,7 +128,7 @@ } }, "getting-started": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M4S", @@ -150,7 +150,7 @@ } }, "editor-setup": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT41S", @@ -172,7 +172,7 @@ } }, "configuration": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT51S", @@ -194,7 +194,7 @@ } }, "fsharp-language": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M53S", @@ -216,7 +216,7 @@ } }, "code-completions": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M9S", @@ -238,7 +238,7 @@ } }, "hover": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M15S", @@ -260,7 +260,7 @@ } }, "navigation": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M0S", @@ -282,7 +282,7 @@ } }, "diagnostics": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M24S", @@ -304,7 +304,7 @@ } }, "refactoring": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M27S", @@ -326,7 +326,7 @@ } }, "context-menus": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M10S", @@ -348,7 +348,7 @@ } }, "nuget": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT1M18S", @@ -370,7 +370,7 @@ } }, "profiler-csharp": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT2M16S", @@ -392,7 +392,7 @@ } }, "architecture": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT38S", @@ -414,7 +414,7 @@ } }, "contributing": { - "encodings": ["webm", "mp4"], + "encodings": ["mp4", "webm"], "width": 1920, "height": 1200, "duration": "PT38S", diff --git a/src/website/tests/media.js b/src/website/tests/media.js index 617a2fc4..74ed62c1 100644 --- a/src/website/tests/media.js +++ b/src/website/tests/media.js @@ -20,6 +20,19 @@ export async function expectPlayable(request, urls, kind) { export const playback = (video) => video.evaluate((element) => ({ paused: element.paused, controls: element.controls, muted: element.muted })); +/** Real decoding, including errors raised AFTER play() resolves. [WEB-DESIGN-MEDIA] */ +export async function expectFramesAdvance(video, from) { + const frames = await video.evaluate((v) => v.getVideoPlaybackQuality().totalVideoFrames); + await expect.poll(() => video.evaluate((v, start) => ({ + error: v.error?.message ?? null, + paused: v.paused, + advancing: v.currentTime > start.time + 1, + decoding: v.getVideoPlaybackQuality().totalVideoFrames > start.frames + 5, + }), { time: from, frames }), { timeout: 15000 }).toEqual({ + error: null, paused: false, advancing: true, decoding: true, + }); +} + export const sourcesOf = (video) => video.locator('source').evaluateAll((sources) => sources.map((s) => s.getAttribute('src'))); diff --git a/src/website/tests/profiler-media.spec.js b/src/website/tests/profiler-media.spec.js index bfa3e7e7..9eb50e3a 100644 --- a/src/website/tests/profiler-media.spec.js +++ b/src/website/tests/profiler-media.spec.js @@ -5,7 +5,7 @@ import { expectPlayable, expectStills, playback, sourcesOf } from './media.js'; const FILM_POSTER = '/assets/videos/profiler-film-poster.webp'; const WALKTHROUGH_POSTER = '/assets/videos/profiler-csharp-poster.webp'; -const FILM_SOURCES = ['/assets/videos/profiler-film.webm', '/assets/videos/profiler-film.mp4']; +const FILM_SOURCES = ['/assets/videos/profiler-film.mp4', '/assets/videos/profiler-film.webm']; const PROFILER_STILLS = 8; const DOCS = ['/docs/profiler/', '/zh/docs/profiler/', '/ja/docs/profiler/']; diff --git a/src/website/tests/site-videos.spec.js b/src/website/tests/site-videos.spec.js index 179a5520..3d68a1f8 100644 --- a/src/website/tests/site-videos.spec.js +++ b/src/website/tests/site-videos.spec.js @@ -65,7 +65,7 @@ function expectDescribed(object, id, label, sources) { expect(object.name, 'search sees the same title the reader hears').toBe(label); expect(object.description.length, 'a real description').toBeGreaterThanOrEqual(20); expect(object.thumbnailUrl).toBe(`${SITE}${poster(id)}`); - expect(object.contentUrl).toBe(`${SITE}${sources.at(-1)}`); + expect(object.contentUrl, 'search links to the preferred playable encoding').toBe(`${SITE}${sources[0]}`); expect(object.duration).toMatch(/^PT(\d+M)?\d+S$/); expect(Number.isNaN(Date.parse(object.uploadDate)), 'uploadDate is a real date').toBe(false); } diff --git a/src/website/tests/video-playback.spec.js b/src/website/tests/video-playback.spec.js new file mode 100644 index 00000000..84f6e499 --- /dev/null +++ b/src/website/tests/video-playback.spec.js @@ -0,0 +1,34 @@ +// Regression for [WEB-DESIGN-MEDIA]: a served file and a resolved play() +// promise do not prove playback. Windows Chrome/Edge decoded one WebM frame, +// then paused with PIPELINE_ERROR_DECODE. Exercise the real media pipeline. +import { test, expect } from '@playwright/test'; +import { expectFramesAdvance } from './media.js'; + +const FILMS = [ + ['/docs/fsharp/', 0], ['/docs/fsharp-profiling/', 0], + ['/docs/', 0], ['/docs/editors/', 0], ['/docs/configuration/', 0], + ['/docs/completions/', 0], ['/docs/hover/', 0], ['/docs/go-to-definition/', 0], + ['/docs/diagnostics/', 0], ['/docs/refactoring/', 0], ['/docs/context-menus/', 0], + ['/docs/nuget/', 0], ['/docs/profiler/', 0], ['/docs/profiler/', 1], + ['/docs/profiler/', 2], ['/docs/memory-leaks/', 0], + ['/docs/architecture/', 0], ['/docs/contributing/', 0], + ['/blog/introducing-sharplsp/', 0], ['/blog/editor-agnostic-dotnet-lsp/', 0], +]; + +for (const [route, index] of FILMS) { + test(`${route} video ${index} decodes, pauses, seeks and resumes`, async ({ page }) => { + await page.goto(route); + const video = page.locator('figure video').nth(index); + await video.scrollIntoViewIfNeeded(); + await video.evaluate((v) => v.play()); + await expectFramesAdvance(video, 0); + await video.evaluate((v) => v.pause()); + await expect(video).toHaveJSProperty('paused', true); + const stopped = await video.evaluate((v) => v.currentTime); + await page.waitForTimeout(250); + await expect(video).toHaveJSProperty('currentTime', stopped); + const seek = await video.evaluate((v) => { v.currentTime = v.duration / 2; return v.currentTime; }); + await video.evaluate((v) => v.play()); + await expectFramesAdvance(video, seek); + }); +} diff --git a/src/website/video-figure.js b/src/website/video-figure.js index 93a533de..b66af890 100644 --- a/src/website/video-figure.js +++ b/src/website/video-figure.js @@ -11,6 +11,8 @@ // films/profiler/render.mjs). The tour is // the Marketplace GIF (assets/images/marketplace/sharplsp-tour.gif) re-encoded // with the same H.264/VP9 settings, because a GIF is eight times the bytes. +// MP4 comes first in the catalogue: Windows Chrome/Edge can accept the VP9 +// source then fail decoding it, without falling back to the next source. import markdownIt from "markdown-it"; import languages from "./src/_data/languages.json" with { type: "json" }; import site from "./src/_data/site.json" with { type: "json" }; @@ -58,7 +60,7 @@ function structuredData(id, video, text) { name: text.label, description: text.caption, thumbnailUrl: `${site.url}${asset(id, "-poster.webp")}`, - contentUrl: `${site.url}${asset(id, `.${video.encodings.at(-1)}`)}`, + contentUrl: `${site.url}${asset(id, `.${video.encodings[0]}`)}`, uploadDate: video.uploadDate, duration: video.duration, width: video.width,