diff --git a/src/cli/traceViewer/screenshotGenerator.ts b/src/cli/traceViewer/screenshotGenerator.ts index ebe4d12bb2..abd4922e1f 100644 --- a/src/cli/traceViewer/screenshotGenerator.ts +++ b/src/cli/traceViewer/screenshotGenerator.ts @@ -27,45 +27,43 @@ const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); export class ScreenshotGenerator { private _traceStorageDir: string; - private _browserPromise: Promise | undefined; + private _browserPromise: Promise; private _traceModel: TraceModel; private _rendering = new Map>(); + private _lock = new Lock(3); constructor(traceStorageDir: string, traceModel: TraceModel) { this._traceStorageDir = traceStorageDir; this._traceModel = traceModel; + this._browserPromise = playwright.chromium.launch(); } - async generateScreenshot(actionId: string): Promise { + generateScreenshot(actionId: string): Promise { const { context, action } = actionById(this._traceModel, actionId); if (!action.action.snapshot) - return; - const imageFileName = path.join(this._traceStorageDir, action.action.snapshot.sha1 + '-thumbnail.png'); - - let body: Buffer | undefined; - try { - body = await fsReadFileAsync(imageFileName); - } catch (e) { - if (!this._rendering.has(action)) { - this._rendering.set(action, this._render(context, action, imageFileName).then(body => { - this._rendering.delete(action); - return body; - })); - } - body = await this._rendering.get(action)!; + return Promise.resolve(undefined); + if (!this._rendering.has(action)) { + this._rendering.set(action, this._render(context, action).then(body => { + this._rendering.delete(action); + return body; + })); } - return body; + return this._rendering.get(action)!; } - private _browser() { - if (!this._browserPromise) - this._browserPromise = playwright.chromium.launch(); - return this._browserPromise; - } + private async _render(contextEntry: ContextEntry, actionEntry: ActionEntry): Promise { + const imageFileName = path.join(this._traceStorageDir, actionEntry.action.snapshot!.sha1 + '-screenshot.png'); + try { + return await fsReadFileAsync(imageFileName); + } catch (e) { + // fall through + } - private async _render(contextEntry: ContextEntry, actionEntry: ActionEntry, imageFileName: string): Promise { const { action } = actionEntry; - const browser = await this._browser(); + const browser = await this._browserPromise; + + await this._lock.obtain(); + const page = await browser.newPage({ viewport: contextEntry.created.viewportSize, deviceScaleFactor: contextEntry.created.deviceScaleFactor @@ -88,49 +86,44 @@ export class ScreenshotGenerator { console.log('Generating screenshot for ' + action.action, snapshotObject.frames[0].url); // eslint-disable-line no-console await page.goto(url); - let clip: any = undefined; const element = await page.$(action.selector || '*[__playwright_target__]'); if (element) { await element.evaluate(e => { e.style.backgroundColor = '#ff69b460'; }); - - clip = await element.boundingBox() || undefined; - if (clip) { - const thumbnailSize = { - width: 400, - height: 200 - }; - const insets = { - width: 60, - height: 30 - }; - clip.width = Math.min(thumbnailSize.width, clip.width); - clip.height = Math.min(thumbnailSize.height, clip.height); - if (clip.width < thumbnailSize.width) { - clip.x -= (thumbnailSize.width - clip.width) / 2; - clip.x = Math.max(0, clip.x); - clip.width = thumbnailSize.width; - } else { - clip.x = Math.max(0, clip.x - insets.width); - } - if (clip.height < thumbnailSize.height) { - clip.y -= (thumbnailSize.height - clip.height) / 2; - clip.y = Math.max(0, clip.y); - clip.height = thumbnailSize.height; - } else { - clip.y = Math.max(0, clip.y - insets.height); - } - } } - - const imageData = await page.screenshot({ clip }); + const imageData = await page.screenshot(); await fsWriteFileAsync(imageFileName, imageData); return imageData; } catch (e) { console.log(e); // eslint-disable-line no-console } finally { await page.close(); + this._lock.release(); } } } + +class Lock { + private _maxWorkers: number; + private _callbacks: (() => void)[] = []; + private _workers = 0; + + constructor(maxWorkers: number) { + this._maxWorkers = maxWorkers; + } + + async obtain() { + while (this._workers === this._maxWorkers) + await new Promise(f => this._callbacks.push(f)); + ++this._workers; + } + + release() { + --this._workers; + const callbacks = this._callbacks; + this._callbacks = []; + for (const callback of callbacks) + callback(); + } +} diff --git a/src/cli/traceViewer/traceViewer.ts b/src/cli/traceViewer/traceViewer.ts index 120d34f02e..3594d9d9f2 100644 --- a/src/cli/traceViewer/traceViewer.ts +++ b/src/cli/traceViewer/traceViewer.ts @@ -22,7 +22,6 @@ import { ScreenshotGenerator } from './screenshotGenerator'; import { SnapshotRouter } from './snapshotRouter'; import { readTraceFile, TraceModel } from './traceModel'; import type { ActionTraceEvent, PageSnapshot, TraceEvent } from '../../trace/traceTypes'; -import { VideoTileGenerator } from './videoTileGenerator'; const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); @@ -31,7 +30,6 @@ type TraceViewerDocument = { model: TraceModel; snapshotRouter: SnapshotRouter; screenshotGenerator: ScreenshotGenerator; - videoTileGenerator: VideoTileGenerator; }; const emptyModel: TraceModel = { @@ -75,7 +73,6 @@ class TraceViewer { resourcesDir, snapshotRouter: new SnapshotRouter(resourcesDir), screenshotGenerator: new ScreenshotGenerator(resourcesDir, model), - videoTileGenerator: new VideoTileGenerator(model) }; for (const name of fs.readdirSync(traceDir)) { @@ -119,7 +116,7 @@ class TraceViewer { console.error(e); return; } - const element = await snapshotFrame.$(action.selector || '*[__playwright_target__]'); + const element = await snapshotFrame.$(action.selector || '*[__playwright_target__]').catch(e => undefined); if (element) { await element.evaluate(e => { e.style.backgroundColor = '#ff69b460'; @@ -130,9 +127,6 @@ class TraceViewer { } }); await uiPage.exposeBinding('getTraceModel', () => this._document ? this._document.model : emptyModel); - await uiPage.exposeBinding('getVideoMetaInfo', async (_, videoId: string) => { - return this._document ? this._document.videoTileGenerator.render(videoId) : null; - }); await uiPage.route('**/*', (route, request) => { if (request.frame().parentFrame() && this._document) { this._document.snapshotRouter.route(route); @@ -151,13 +145,7 @@ class TraceViewer { }); return; } - let filePath: string; - if (this._document && request.url().includes('video-tile')) { - const fullPath = url.pathname.substring('/video-tile/'.length); - filePath = this._document.videoTileGenerator.tilePath(fullPath); - } else { - filePath = path.join(__dirname, 'web', url.pathname.substring(1)); - } + const filePath = path.join(__dirname, 'web', url.pathname.substring(1)); const body = fs.readFileSync(filePath); route.fulfill({ contentType: extensionToMime[path.extname(url.pathname).substring(1)] || 'text/plain', diff --git a/src/cli/traceViewer/videoTileGenerator.ts b/src/cli/traceViewer/videoTileGenerator.ts deleted file mode 100644 index d5eaff890b..0000000000 --- a/src/cli/traceViewer/videoTileGenerator.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { spawnSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as util from 'util'; -import { TraceModel, videoById, VideoMetaInfo } from './traceModel'; -import type { PageVideoTraceEvent } from '../../trace/traceTypes'; -import { ffmpegExecutable } from '../../utils/binaryPaths'; - -const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); -const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); - -export class VideoTileGenerator { - private _traceModel: TraceModel; - - constructor(traceModel: TraceModel) { - this._traceModel = traceModel; - } - - tilePath(urlPath: string) { - const index = urlPath.lastIndexOf('/'); - const tile = urlPath.substring(index + 1); - const videoId = urlPath.substring(0, index); - const { context, page } = videoById(this._traceModel, videoId); - const videoFilePath = path.join(path.dirname(context.filePath), page.video!.video.fileName); - return videoFilePath + '-' + tile; - } - - async render(videoId: string): Promise { - const { context, page } = videoById(this._traceModel, videoId); - const video = page.video!.video; - const videoFilePath = path.join(path.dirname(context.filePath), video.fileName); - const metaInfoFilePath = videoFilePath + '-metainfo.txt'; - try { - const metaInfo = await fsReadFileAsync(metaInfoFilePath, 'utf8'); - return metaInfo ? JSON.parse(metaInfo) : undefined; - } catch (e) { - } - - const ffmpeg = ffmpegExecutable()!; - console.log('Generating frames for ' + videoFilePath); // eslint-disable-line no-console - // Force output frame rate to 25 fps as otherwise it would produce one image per timebase unit - // which is currently 1 / (25 * 1000). - const result = spawnSync(ffmpeg, ['-i', videoFilePath, '-r', '25', `${videoFilePath}-%03d.png`]); - const metaInfo = parseMetaInfo(result.stderr.toString(), video); - await fsWriteFileAsync(metaInfoFilePath, metaInfo ? JSON.stringify(metaInfo) : ''); - return metaInfo; - } -} - -function parseMetaInfo(text: string, video: PageVideoTraceEvent): VideoMetaInfo | undefined { - const lines = text.split('\n'); - let framesLine = lines.find(l => l.startsWith('frame=')); - if (!framesLine) - return; - framesLine = framesLine.substring(framesLine.lastIndexOf('frame=')); - const framesMatch = framesLine.match(/frame=\s+(\d+)/); - const outputLineIndex = lines.findIndex(l => l.trim().startsWith('Output #0')); - const streamLine = lines.slice(outputLineIndex).find(l => l.trim().startsWith('Stream #0:0'))!; - const fpsMatch = streamLine.match(/, (\d+) fps,/); - const resolutionMatch = streamLine.match(/, (\d+)x(\d+)\D/); - const durationMatch = lines.find(l => l.trim().startsWith('Duration'))!.match(/Duration: (\d+):(\d\d):(\d\d.\d\d)/); - const duration = (((parseInt(durationMatch![1], 10) * 60) + parseInt(durationMatch![2], 10)) * 60 + parseFloat(durationMatch![3])) * 1000; - return { - frames: parseInt(framesMatch![1], 10), - width: parseInt(resolutionMatch![1], 10), - height: parseInt(resolutionMatch![2], 10), - fps: parseInt(fpsMatch![1], 10), - startTime: video.timestamp, - endTime: video.timestamp + duration - }; -} diff --git a/src/cli/traceViewer/web/index.tsx b/src/cli/traceViewer/web/index.tsx index 07119db4fb..8b915d0a44 100644 --- a/src/cli/traceViewer/web/index.tsx +++ b/src/cli/traceViewer/web/index.tsx @@ -24,7 +24,6 @@ import { applyTheme } from './theme'; declare global { interface Window { getTraceModel(): Promise; - getVideoMetaInfo(videoId: string): Promise; readFile(filePath: string): Promise; renderSnapshot(action: trace.ActionTraceEvent): void; } diff --git a/src/cli/traceViewer/web/ui/filmStrip.css b/src/cli/traceViewer/web/ui/filmStrip.css deleted file mode 100644 index d0169adceb..0000000000 --- a/src/cli/traceViewer/web/ui/filmStrip.css +++ /dev/null @@ -1,45 +0,0 @@ -/* - Copyright (c) Microsoft Corporation. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -.film-strip { - flex: none; - display: flex; - flex-direction: column; - position: relative; -} - -.film-strip-lane { - flex: none; - display: flex; -} - -.film-strip-frame { - flex: none; - pointer-events: none; - box-shadow: var(--box-shadow); -} - -.film-strip-hover { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-color: white; - box-shadow: rgba(0, 0, 0, 0.133) 0px 1.6px 3.6px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 0.9px 0px; - box-shadow: rgba(0, 0, 0, 0.133) 0px 1.6px 10px 0px, rgba(0, 0, 0, 0.11) 0px 0.3px 10px 0px; - z-index: 10; -} diff --git a/src/cli/traceViewer/web/ui/filmStrip.tsx b/src/cli/traceViewer/web/ui/filmStrip.tsx deleted file mode 100644 index 5d91b809f5..0000000000 --- a/src/cli/traceViewer/web/ui/filmStrip.tsx +++ /dev/null @@ -1,136 +0,0 @@ -/* - Copyright (c) Microsoft Corporation. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import { ContextEntry, VideoEntry, VideoMetaInfo } from '../../traceModel'; -import './filmStrip.css'; -import { Boundaries } from '../geometry'; -import * as React from 'react'; -import { useAsyncMemo, useMeasure } from './helpers'; - -function imageURL(videoId: string, index: number) { - const imageURLpadding = '0'.repeat(3 - String(index + 1).length); - return `video-tile/${videoId}/${imageURLpadding}${index + 1}.png`; -} - -export const FilmStrip: React.FunctionComponent<{ - context: ContextEntry, - boundaries: Boundaries, - previewX?: number, -}> = ({ context, boundaries, previewX }) => { - const [measure, ref] = useMeasure(); - - const videos = React.useMemo(() => { - const videos: VideoEntry[] = []; - for (const page of context.pages) { - if (page.video) - videos.push(page.video); - } - return videos; - }, [context]); - - const metaInfos = useAsyncMemo>(async () => { - const infos = new Map(); - for (const video of videos) - infos.set(video, await window.getVideoMetaInfo(video.videoId)); - return infos; - }, [videos], new Map(), new Map()); - - // TODO: pick file from the Y position. - const previewVideo = videos[0]; - const previewMetaInfo = metaInfos.get(previewVideo); - let previewIndex = 0; - if ((previewX !== undefined) && previewMetaInfo) { - const previewTime = boundaries.minimum + (boundaries.maximum - boundaries.minimum) * previewX / measure.width; - previewIndex = (previewTime - previewMetaInfo.startTime) / (previewMetaInfo.endTime - previewMetaInfo.startTime) * previewMetaInfo.frames | 0; - } - - const previewImage = useAsyncMemo(async () => { - if (!previewMetaInfo || previewIndex < 0 || previewIndex >= previewMetaInfo.frames) - return; - const idealWidth = previewMetaInfo.width / 2; - const idealHeight = previewMetaInfo.height / 2; - const ratio = Math.min(1, (measure.width - 20) / idealWidth); - const image = new Image((idealWidth * ratio) | 0, (idealHeight * ratio) | 0); - image.src = imageURL(previewVideo.videoId, previewIndex); - await new Promise(f => image.onload = f); - return image; - }, [previewMetaInfo, previewIndex, measure.width, previewVideo], undefined); - - return
{ - videos.map(video => ) - } - {(previewX !== undefined) && previewMetaInfo && previewImage && -
- -
- } -
; -}; - -const FilmStripLane: React.FunctionComponent<{ - boundaries: Boundaries, - video: VideoEntry, - metaInfo: VideoMetaInfo | undefined, - width: number, -}> = ({ boundaries, video, metaInfo, width }) => { - const frameHeight = 45; - const frameMargin = 2.5; - - if (!metaInfo) - return
; - - const frameWidth = frameHeight / metaInfo.height * metaInfo.width | 0; - const boundariesSize = boundaries.maximum - boundaries.minimum; - const gapLeft = (metaInfo.startTime - boundaries.minimum) / boundariesSize * width; - const gapRight = (boundaries.maximum - metaInfo.endTime) / boundariesSize * width; - const effectiveWidth = (metaInfo.endTime - metaInfo.startTime) / boundariesSize * width; - - const frameCount = effectiveWidth / (frameWidth + 2 * frameMargin) | 0; - const frameStep = metaInfo.frames / frameCount; - const frameGap = frameCount <= 1 ? 0 : (effectiveWidth - (frameWidth + 2 * frameMargin) * frameCount) / (frameCount - 1); - - const frames: JSX.Element[] = []; - for (let i = 0; i < metaInfo.frames; i += frameStep) { - let index = i | 0; - // Always show last frame. - if (Math.floor(i + frameStep) >= metaInfo.frames) - index = metaInfo.frames - 1; - frames.push(
); - } - - return
{frames}
; -}; diff --git a/src/cli/traceViewer/web/ui/timeline.tsx b/src/cli/traceViewer/web/ui/timeline.tsx index ce784a72f3..1e580ee7f5 100644 --- a/src/cli/traceViewer/web/ui/timeline.tsx +++ b/src/cli/traceViewer/web/ui/timeline.tsx @@ -17,7 +17,6 @@ import { ContextEntry, InterestingPageEvent, ActionEntry, trace } from '../../traceModel'; import './timeline.css'; -import { FilmStrip } from './filmStrip'; import { Boundaries } from '../geometry'; import * as React from 'react'; import { useMeasure } from './helpers'; @@ -189,7 +188,6 @@ export const Timeline: React.FunctionComponent<{ >
; }) } -