Skip to content
This repository was archived by the owner on Jul 2, 2021. It is now read-only.

Refactor #8

Merged
merged 10 commits into from
Mar 3, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"tslib": "^1.11.1"
},
"devDependencies": {
"@types/debug": "^4.1.5",
"@types/fluent-ffmpeg": "^2.1.14",
"@types/fs-extra": "^8.1.0",
"@types/jest": "^25.1.3",
Expand Down
74 changes: 74 additions & 0 deletions src/PageVideoCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Debug from 'debug';
import { Page } from 'playwright-core';
import { CRBrowser } from 'playwright-core/lib/chromium/crBrowser';
import { ScreencastFrameCollector } from './ScreencastFrameCollector';
import { VideoFrameBuilder } from './VideoFrameBuilder';
import { VideoWriter } from './VideoWriter';

const debug = Debug('playwright-video:PageVideoCapture');

interface ConstructorArgs {
collector: ScreencastFrameCollector;
writer: VideoWriter;
}

interface CreateArgs {
browser: CRBrowser;
page: Page;
savePath: string;
}

export class PageVideoCapture {
public static async start({
browser,
page,
savePath,
}: CreateArgs): Promise<PageVideoCapture> {
debug('start');

const collector = await ScreencastFrameCollector.create({ browser, page });
const writer = await VideoWriter.create(savePath);

const capture = new PageVideoCapture({ collector, writer });
page.on('close', () => capture.stop());

await collector.start();

return capture;
}

private _collector: ScreencastFrameCollector;
private _frameBuilder: VideoFrameBuilder = new VideoFrameBuilder();
private _stopped = false;
private _writer: VideoWriter;

protected constructor({ collector, writer }: ConstructorArgs) {
this._collector = collector;
this._writer = writer;

this._writer.on('ffmpegerror', () => {
debug('stop due to ffmpeg error');
this.stop();
});
this._listenForFrames();
}

private _listenForFrames(): void {
this._collector.on('screencastframe', screencastFrame => {
debug(`received frame: ${screencastFrame.timestamp}`);
const videoFrames = this._frameBuilder.buildVideoFrames(screencastFrame);

this._writer.write(videoFrames);
});
}

public async stop(): Promise<void> {
if (this._stopped) return;

this._stopped = true;
debug('stop');

await this._collector.stop();
return this._writer.stop();
}
}
85 changes: 85 additions & 0 deletions src/ScreencastFrameCollector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import Debug from 'debug';
import { EventEmitter } from 'events';
import { Page } from 'playwright-core';
import { CRBrowser } from 'playwright-core/lib/chromium/crBrowser';
import { CRSession } from 'playwright-core/lib/chromium/crConnection';
import { ensureBrowserType } from './utils';

const debug = Debug('playwright-video:FrameCollector');

interface ConstructorArgs {
browser: CRBrowser;
page: Page;
}

export class ScreencastFrameCollector extends EventEmitter {
public static async create(
args: ConstructorArgs,
): Promise<ScreencastFrameCollector> {
ensureBrowserType(args.browser);

const frameCollector = new ScreencastFrameCollector(args);
await frameCollector._buildClient(args.browser);

return frameCollector;
}

private _client: CRSession;
private _page: Page;
private _stopped = false;

protected constructor({ page }: ConstructorArgs) {
super();
this._page = page;
}

private async _buildClient(browser: CRBrowser): Promise<void> {
this._client = await browser.pageTarget(this._page).createCDPSession();

this._listenForFrames();
}

private _listenForFrames(): void {
this._client.on('Page.screencastFrame', payload => {
debug(`received frame with timestamp ${payload.metadata.timestamp}`);

this._client.send('Page.screencastFrameAck', {
sessionId: payload.sessionId,
});

if (!payload.metadata.timestamp) {
debug('skip frame without timestamp');
return;
}

this.emit('screencastframe', {
data: Buffer.from(payload.data, 'base64'),
received: Date.now(),
timestamp: payload.metadata.timestamp,
});
});
}

public async start(): Promise<void> {
debug('start');

await this._client.send('Page.startScreencast', {
everyNthFrame: 1,
});
}

public async stop(): Promise<void> {
if (this._stopped) return;

debug('stop');
this._stopped = true;
// Screencast API takes time to send frames
// Wait 1s for frames to arrive
// TODO figure out a better pattern for this
await new Promise(resolve => setTimeout(resolve, 1000));

if (this._client._connection) {
await this._client.detach();
}
}
}
2 changes: 1 addition & 1 deletion src/VideoCapture.ts → src/VideoCapture.old.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class VideoCapture {
}

private _captureVideo(savePath: string): void {
debug(`capture video to ${savePath} at ${this._inputFps}fps`);
debug(`write video to ${savePath} at ${this._inputFps}fps`);

this._endedPromise = new Promise((resolve, reject) => {
ffmpeg({ source: this._stream, priority: 20 })
Expand Down
51 changes: 51 additions & 0 deletions src/VideoFrameBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Debug from 'debug';

const debug = Debug('playwright-video:VideoFrameBuilder');

interface ScreencastFrame {
data: Buffer;
received: number;
timestamp: number;
}

export class VideoFrameBuilder {
private _framesPerSecond = 25;
private _previousFrame?: ScreencastFrame;

private _getFrameCount(screencastFrame?: ScreencastFrame): number {
let durationSeconds: number;

if (screencastFrame) {
// measure duration between frames
durationSeconds =
screencastFrame.timestamp - this._previousFrame.timestamp;
} else {
// measure duration since the last frame was received
durationSeconds = (Date.now() - this._previousFrame.received) / 1000;
}

return Math.round(durationSeconds * this._framesPerSecond);
}

public buildVideoFrames(screencastFrame?: ScreencastFrame): Buffer[] {
if (!this._previousFrame) {
debug('first frame received: waiting for more');
this._previousFrame = screencastFrame;
return [];
}

const frameCount = this._getFrameCount(screencastFrame);

if (frameCount < 0) {
debug('frames out of order: skipping frame');
return [];
}

const frames = Array(frameCount).fill(this._previousFrame.data);
debug(`returning ${frames.length} frames`);

this._previousFrame = screencastFrame;

return frames;
}
}
80 changes: 80 additions & 0 deletions src/VideoWriter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Debug from 'debug';
import { EventEmitter } from 'events';
import * as ffmpeg from 'fluent-ffmpeg';
import { ensureDir } from 'fs-extra';
import { dirname } from 'path';
import { PassThrough } from 'stream';
import { ensureFfmpegPath } from './utils';

const debug = Debug('playwright-video:VideoWriter');

export class VideoWriter extends EventEmitter {
public static async create(savePath: string): Promise<VideoWriter> {
await ensureDir(dirname(savePath));

return new VideoWriter(savePath);
}

private _endedPromise: Promise<void>;
private _framesPerSecond = 25;
private _receivedFrame = false;
private _stopped = false;
private _stream: PassThrough = new PassThrough();

protected constructor(savePath: string) {
super();

ensureFfmpegPath();
this._writeVideo(savePath);
}

private _writeVideo(savePath: string): void {
debug(`write video to ${savePath}`);

this._endedPromise = new Promise((resolve, reject) => {
ffmpeg({ source: this._stream, priority: 20 })
.videoCodec('libx264')
.inputFormat('image2pipe')
.inputFPS(this._framesPerSecond)
.outputOptions('-preset ultrafast')
.on('error', e => {
this.emit('ffmpegerror');
// do not reject as a result of not having frames
if (
!this._receivedFrame &&
e.message.includes('pipe:0: End of file')
) {
resolve();
return;
}

reject(`playwright-video: error capturing video: ${e.message}`);
})
.on('end', () => {
resolve();
})
.save(savePath);
});
}

public stop(): Promise<void> {
if (this._stopped) {
return this._endedPromise;
}

this._stopped = true;
this._stream.end();

return this._endedPromise;
}

public write(frames: Buffer[]): void {
if (frames.length && !this._receivedFrame) {
this._receivedFrame = true;
}

frames.forEach(frame => {
this._stream.write(frame);
});
}
}
4 changes: 2 additions & 2 deletions src/example.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { chromium, devices } from 'playwright-core';
import { VideoCapture } from './VideoCapture';
import { PageVideoCapture } from './PageVideoCapture';

(async (): Promise<void> => {
const iPhone = devices['iPhone 6'];
Expand All @@ -12,7 +12,7 @@ import { VideoCapture } from './VideoCapture';

const page = await context.newPage();

await VideoCapture.start({
await PageVideoCapture.start({
browser,
page,
savePath: '/tmp/video.mp4',
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { getFfmpegPath } from './utils';
export { VideoCapture } from './VideoCapture';
export { PageVideoCapture } from './PageVideoCapture';
3 changes: 3 additions & 0 deletions tests/PageVideoCapture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
describe('PageVideoCapture', () => {
it('records a video of the page', async () => {});
});
7 changes: 7 additions & 0 deletions tests/ScreencastFrameCollector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
describe('ScreencastFrameCollector', () => {
it('emits frames on a page', async () => {});

it('throws an error if browser not a ChromiumBrowser instance', async () => {});

it('disposes the CDP session when stopped', async () => {});
});
7 changes: 7 additions & 0 deletions tests/VideoFrameBuilder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
describe('VideoFrameBuilder', () => {
it('returns empty array if no previous frame', async () => {});

it('returns array of previous frame with correct length', async () => {});

it('returns empty array if frames out of order', async () => {});
});
5 changes: 5 additions & 0 deletions tests/VideoWriter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
describe('VideoWriter', () => {
it('throws an error when ffmpeg path is not found', async () => {});

it('saves the video', async () => {});
});