@design.estate/wcctools 6.2.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +21 -0
- package/dist_shell/bundle.js +4 -4
- package/dist_shell/bundle.js.map +1 -1
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/capture/classes.capturebrowser.d.ts +65 -0
- package/dist_ts/capture/classes.capturebrowser.js +251 -0
- package/dist_ts/capture/classes.captureservice.d.ts +39 -0
- package/dist_ts/capture/classes.captureservice.js +119 -0
- package/dist_ts/capture/classes.screencast.d.ts +47 -0
- package/dist_ts/capture/classes.screencast.js +127 -0
- package/dist_ts/capture/errors.d.ts +14 -0
- package/dist_ts/capture/errors.js +23 -0
- package/dist_ts/capture/images.d.ts +3 -0
- package/dist_ts/capture/images.js +58 -0
- package/dist_ts/capture/index.d.ts +4 -0
- package/dist_ts/capture/index.js +5 -0
- package/dist_ts/capture/interaction.d.ts +11 -0
- package/dist_ts/capture/interaction.js +138 -0
- package/dist_ts/capture/navigationguard.d.ts +44 -0
- package/dist_ts/capture/navigationguard.js +129 -0
- package/dist_ts/capture/previewpage.d.ts +29 -0
- package/dist_ts/capture/previewpage.js +96 -0
- package/dist_ts/capture/request.d.ts +69 -0
- package/dist_ts/capture/request.js +239 -0
- package/dist_ts/classes.devserver.d.ts +25 -9
- package/dist_ts/classes.devserver.js +45 -14
- package/dist_ts/classes.hostpolicy.d.ts +63 -0
- package/dist_ts/classes.hostpolicy.js +171 -0
- package/dist_ts/cli.js +25 -3
- package/dist_ts/cli.screenshot.d.ts +6 -0
- package/dist_ts/cli.screenshot.js +89 -0
- package/dist_ts/plugins.d.ts +6 -2
- package/dist_ts/plugins.js +8 -3
- package/dist_ts_interfaces/capture.d.ts +151 -0
- package/dist_ts_interfaces/capture.js +2 -0
- package/dist_ts_interfaces/index.d.ts +1 -0
- package/dist_ts_interfaces/index.js +2 -1
- package/dist_ts_interfaces/requests.d.ts +24 -0
- package/dist_ts_shell/elements/wcc-recording-panel.d.ts +9 -0
- package/dist_ts_shell/elements/wcc-recording-panel.js +12 -1
- package/dist_ts_shell/services/framesampler.service.d.ts +23 -0
- package/dist_ts_shell/services/framesampler.service.js +101 -0
- package/dist_ts_web/00_commitinfo_data.js +1 -1
- package/package.json +2 -1
- package/readme.md +104 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/capture/classes.capturebrowser.ts +290 -0
- package/ts/capture/classes.captureservice.ts +152 -0
- package/ts/capture/classes.screencast.ts +152 -0
- package/ts/capture/errors.ts +25 -0
- package/ts/capture/images.ts +61 -0
- package/ts/capture/index.ts +4 -0
- package/ts/capture/interaction.ts +146 -0
- package/ts/capture/navigationguard.ts +147 -0
- package/ts/capture/previewpage.ts +130 -0
- package/ts/capture/request.ts +304 -0
- package/ts/classes.devserver.ts +55 -13
- package/ts/classes.hostpolicy.ts +201 -0
- package/ts/cli.screenshot.ts +95 -0
- package/ts/cli.ts +25 -2
- package/ts/plugins.ts +9 -2
- package/ts_interfaces/capture.ts +144 -0
- package/ts_interfaces/index.ts +1 -0
- package/ts_interfaces/requests.ts +39 -0
- package/ts_shell/elements/wcc-recording-panel.ts +12 -0
- package/ts_shell/services/framesampler.service.ts +112 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { WccDevServer } from './classes.devserver.js';
|
|
3
|
+
import { WccCaptureRequestError, captureViewportWidths, resolveScreenshotRequest } from './capture/index.js';
|
|
4
|
+
|
|
5
|
+
/** Reads `<item>` or `<section>/<item>`. */
|
|
6
|
+
const parseSubject = (argsArg: readonly string[], demoArg: number | undefined): plugins.interfaces.IWccCaptureSubject => {
|
|
7
|
+
if (argsArg.length !== 1 || argsArg[0].trim() === '') {
|
|
8
|
+
throw new WccCaptureRequestError('wcctools screenshot: name one catalog entry, as <item> or <section>/<item>.');
|
|
9
|
+
}
|
|
10
|
+
const [subject] = argsArg;
|
|
11
|
+
const slash = subject.indexOf('/');
|
|
12
|
+
const named: plugins.interfaces.IWccCaptureSubject = slash === -1
|
|
13
|
+
? { itemName: subject }
|
|
14
|
+
: { sectionName: subject.slice(0, slash), itemName: subject.slice(slash + 1) };
|
|
15
|
+
return demoArg === undefined ? named : { ...named, demoIndex: demoArg };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Reads a named viewport or a width in pixels. */
|
|
19
|
+
const parseViewport = (valueArg: string | undefined): plugins.interfaces.TWccCaptureViewport | undefined => {
|
|
20
|
+
if (valueArg === undefined) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (Object.hasOwn(captureViewportWidths, valueArg)) {
|
|
24
|
+
return valueArg as plugins.interfaces.TWccCaptureViewportPreset;
|
|
25
|
+
}
|
|
26
|
+
if (/^\d+$/.test(valueArg)) {
|
|
27
|
+
return Number(valueArg);
|
|
28
|
+
}
|
|
29
|
+
throw new WccCaptureRequestError(
|
|
30
|
+
`wcctools screenshot: --viewport must be one of ${Object.keys(captureViewportWidths).join(', ')} or a width in pixels.`,
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The image format the output file's extension names. */
|
|
35
|
+
const formatForFile = (fileArg: string): 'png' | 'jpeg' => {
|
|
36
|
+
const extension = plugins.path.extname(fileArg).toLowerCase();
|
|
37
|
+
if (extension === '.png') {
|
|
38
|
+
return 'png';
|
|
39
|
+
}
|
|
40
|
+
if (extension === '.jpg' || extension === '.jpeg') {
|
|
41
|
+
return 'jpeg';
|
|
42
|
+
}
|
|
43
|
+
throw new WccCaptureRequestError('wcctools screenshot: --out must name a .png, .jpg or .jpeg file.');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `wcctools screenshot`: bundles the catalog of the working directory, serves it on a free
|
|
48
|
+
* loopback port, screenshots one demo in a headless browser, writes the image and stops.
|
|
49
|
+
*/
|
|
50
|
+
export const registerScreenshotCommand = (out: plugins.smartconsole.SmartConsole): void => {
|
|
51
|
+
out.cli.command({
|
|
52
|
+
name: 'screenshot',
|
|
53
|
+
description: 'Screenshot one demo of the catalog in a headless browser: wcctools screenshot <item | section/item> --out <file.png>',
|
|
54
|
+
options: {
|
|
55
|
+
out: { type: 'string', required: true, description: 'The image file to write; .png, .jpg or .jpeg' },
|
|
56
|
+
demo: { type: 'number', description: 'The demo to render, counted from 0 as in the shell URL (default 0)' },
|
|
57
|
+
viewport: { type: 'string', description: `The width: ${Object.entries(captureViewportWidths).map(([nameArg, widthArg]) => `${nameArg} (${widthArg})`).join(', ')} or pixels (default desktop)` },
|
|
58
|
+
height: { type: 'number', description: 'The height of the preview window in pixels (default 800)' },
|
|
59
|
+
theme: { type: 'string', description: 'dark or bright (default dark)' },
|
|
60
|
+
framing: { type: 'string', description: 'element, viewport or fullpage (default element; fullpage for pages)' },
|
|
61
|
+
scale: { type: 'number', description: 'Device pixel ratio, 1 or 2 (default 1)' },
|
|
62
|
+
quality: { type: 'number', description: 'JPEG quality from 1 to 100 (default 80)' },
|
|
63
|
+
},
|
|
64
|
+
}, async ({ args, options }) => {
|
|
65
|
+
const format = formatForFile(options.out);
|
|
66
|
+
const request: plugins.interfaces.IWccScreenshotRequest = {
|
|
67
|
+
subject: parseSubject(args, options.demo),
|
|
68
|
+
viewport: parseViewport(options.viewport),
|
|
69
|
+
height: options.height,
|
|
70
|
+
theme: options.theme as plugins.interfaces.TWccTheme | undefined,
|
|
71
|
+
framing: options.framing as plugins.interfaces.TWccCaptureFraming | undefined,
|
|
72
|
+
scale: options.scale as 1 | 2 | undefined,
|
|
73
|
+
format,
|
|
74
|
+
quality: options.quality,
|
|
75
|
+
};
|
|
76
|
+
// Invalid options are refused before anything is bundled
|
|
77
|
+
resolveScreenshotRequest(request);
|
|
78
|
+
const outFile = plugins.path.resolve(options.out);
|
|
79
|
+
const devServer = new WccDevServer({ port: 0, cwd: process.cwd() });
|
|
80
|
+
// As for `wcctools dev`: SIGINT and SIGTERM stop the server and the capture browser once
|
|
81
|
+
plugins.smartexit.ProcessLifecycle.install({ shutdownTimeoutMs: devServer.getShutdownTimeoutMs() });
|
|
82
|
+
let result: plugins.interfaces.IWccScreenshotResult;
|
|
83
|
+
try {
|
|
84
|
+
await devServer.start();
|
|
85
|
+
result = await devServer.capture.captureScreenshot(request);
|
|
86
|
+
} finally {
|
|
87
|
+
await devServer.stop();
|
|
88
|
+
}
|
|
89
|
+
await plugins.fs.writeFile(outFile, Buffer.from(result.image.dataBase64, 'base64'));
|
|
90
|
+
const { selection, image } = result;
|
|
91
|
+
await out.log(
|
|
92
|
+
`wcctools screenshot: wrote ${outFile} (${selection.sectionName}/${selection.itemName} demo ${selection.demoIndex}, ${result.framing}, ${image.width}×${image.height}${result.truncated ? ', cut at the maximum height' : ''})`,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
};
|
package/ts/cli.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { commitinfo } from './00_commitinfo_data.js';
|
|
3
3
|
import { WccDevServer } from './classes.devserver.js';
|
|
4
|
+
import { WccHostPolicyError } from './classes.hostpolicy.js';
|
|
5
|
+
import { WccCaptureRequestError } from './capture/index.js';
|
|
6
|
+
import { registerScreenshotCommand } from './cli.screenshot.js';
|
|
4
7
|
import { StandardConfigError, StandardUsageError, registerStandardCommands } from './standards/index.js';
|
|
5
8
|
|
|
6
9
|
/**
|
|
@@ -21,16 +24,34 @@ export const registerCommands = (out: plugins.smartconsole.SmartConsole): void =
|
|
|
21
24
|
type: 'number',
|
|
22
25
|
description: 'Port to listen on (0 picks a free port); overrides the tswatch configuration',
|
|
23
26
|
},
|
|
27
|
+
host: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Interface address to listen on (default 127.0.0.1, this machine only; 0.0.0.0 for every interface)',
|
|
30
|
+
},
|
|
31
|
+
allowedHost: {
|
|
32
|
+
type: 'strings',
|
|
33
|
+
description: 'A further hostname or address the server answers, besides localhost and the listen address (repeatable)',
|
|
34
|
+
},
|
|
24
35
|
},
|
|
25
36
|
}, async ({ options }) => {
|
|
26
|
-
const devServer = new WccDevServer({
|
|
37
|
+
const devServer = new WccDevServer({
|
|
38
|
+
port: options.port,
|
|
39
|
+
cwd: process.cwd(),
|
|
40
|
+
host: options.host,
|
|
41
|
+
allowedHosts: options.allowedHost,
|
|
42
|
+
});
|
|
27
43
|
// The entry point owns process signals: SIGINT and SIGTERM run every registered cleanup, the
|
|
28
44
|
// dev server's stop among them, once within tswatch's shutdown deadline, then exit.
|
|
29
45
|
plugins.smartexit.ProcessLifecycle.install({ shutdownTimeoutMs: devServer.getShutdownTimeoutMs() });
|
|
30
46
|
const address = await devServer.start();
|
|
31
47
|
await out.log(`wcctools dev: ${address.url}`);
|
|
48
|
+
if (address.host !== '127.0.0.1' && address.host !== '::1' && address.host !== 'localhost') {
|
|
49
|
+
await out.log(`wcctools dev: listening on ${address.host}, answering the hosts ${address.allowedHosts.join(', ')}`);
|
|
50
|
+
}
|
|
32
51
|
});
|
|
33
52
|
|
|
53
|
+
registerScreenshotCommand(out);
|
|
54
|
+
|
|
34
55
|
registerStandardCommands(out);
|
|
35
56
|
|
|
36
57
|
out.cli.default({}, async () => {
|
|
@@ -48,7 +69,9 @@ export const runCli = async () => {
|
|
|
48
69
|
// Configuration and usage errors exit with 2, every other failure with 1.
|
|
49
70
|
const usageError = error instanceof plugins.smartconsole.CliUsageError
|
|
50
71
|
|| error instanceof StandardConfigError
|
|
51
|
-
|| error instanceof StandardUsageError
|
|
72
|
+
|| error instanceof StandardUsageError
|
|
73
|
+
|| error instanceof WccCaptureRequestError
|
|
74
|
+
|| error instanceof WccHostPolicyError;
|
|
52
75
|
process.exitCode = usageError ? 2 : 1;
|
|
53
76
|
} finally {
|
|
54
77
|
await out.dispose();
|
package/ts/plugins.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import * as childProcess from 'node:child_process';
|
|
3
3
|
import * as fs from 'node:fs/promises';
|
|
4
4
|
import * as fsSync from 'node:fs';
|
|
5
|
+
import * as os from 'node:os';
|
|
5
6
|
import * as path from 'node:path';
|
|
6
7
|
import * as url from 'node:url';
|
|
7
8
|
|
|
8
|
-
export { childProcess, fs, fsSync, path, url };
|
|
9
|
+
export { childProcess, fs, fsSync, os, path, url };
|
|
9
10
|
|
|
10
11
|
// @api.global scope
|
|
11
12
|
import * as typedrequest from '@api.global/typedrequest';
|
|
@@ -13,17 +14,23 @@ import * as typedserver from '@api.global/typedserver';
|
|
|
13
14
|
|
|
14
15
|
export { typedrequest, typedserver };
|
|
15
16
|
|
|
17
|
+
// @design.estate scope
|
|
18
|
+
import * as deesDomtools from '@design.estate/dees-domtools';
|
|
19
|
+
|
|
20
|
+
export { deesDomtools };
|
|
21
|
+
|
|
16
22
|
// @git.zone scope
|
|
17
23
|
import * as tswatch from '@git.zone/tswatch';
|
|
18
24
|
|
|
19
25
|
export { tswatch };
|
|
20
26
|
|
|
21
27
|
// @push.rocks scope
|
|
28
|
+
import * as smartbrowser from '@push.rocks/smartbrowser/automation';
|
|
22
29
|
import * as smartconfig from '@push.rocks/smartconfig';
|
|
23
30
|
import * as smartconsole from '@push.rocks/smartconsole';
|
|
24
31
|
import * as smartexit from '@push.rocks/smartexit';
|
|
25
32
|
|
|
26
|
-
export { smartconfig, smartconsole, smartexit };
|
|
33
|
+
export { smartbrowser, smartconfig, smartconsole, smartexit };
|
|
27
34
|
|
|
28
35
|
// third party scope
|
|
29
36
|
import typescript from 'typescript';
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { IWccSelection, TWccTheme } from './bridge.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Captures of the catalog's demos: screenshots and interaction frames the `wcctools dev` server
|
|
5
|
+
* takes in a headless browser, and frames the shell samples from a recording.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The named capture widths: the dees-domtools breakpoints. */
|
|
9
|
+
export type TWccCaptureViewportPreset = 'phone' | 'phablet' | 'tablet' | 'desktop';
|
|
10
|
+
|
|
11
|
+
/** A named capture width, or a width in CSS pixels. */
|
|
12
|
+
export type TWccCaptureViewport = TWccCaptureViewportPreset | number;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What a capture shows:
|
|
16
|
+
* - `element`: the rendered instance of the selected element (the first one its demo renders),
|
|
17
|
+
* - `viewport`: the visible preview at the capture's width and height,
|
|
18
|
+
* - `fullpage`: the whole preview document.
|
|
19
|
+
*/
|
|
20
|
+
export type TWccCaptureFraming = 'element' | 'viewport' | 'fullpage';
|
|
21
|
+
|
|
22
|
+
export type TWccImageMimeType = 'image/png' | 'image/jpeg';
|
|
23
|
+
|
|
24
|
+
/** An encoded image. */
|
|
25
|
+
export interface IWccImage {
|
|
26
|
+
mimeType: TWccImageMimeType;
|
|
27
|
+
/** Width in device pixels. */
|
|
28
|
+
width: number;
|
|
29
|
+
/** Height in device pixels. */
|
|
30
|
+
height: number;
|
|
31
|
+
dataBase64: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A frame of a recording or of an observed interaction. */
|
|
35
|
+
export interface IWccTimedFrame extends IWccImage {
|
|
36
|
+
/** Milliseconds since the start of the recording or observation. */
|
|
37
|
+
timestampMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The catalog entry a capture renders. `itemName` is an entry's name or its element's tag;
|
|
42
|
+
* without `sectionName` it must name exactly one entry across all sections.
|
|
43
|
+
*/
|
|
44
|
+
export interface IWccCaptureSubject {
|
|
45
|
+
sectionName?: string;
|
|
46
|
+
itemName: string;
|
|
47
|
+
/** 0-based demo index; default 0. */
|
|
48
|
+
demoIndex?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** How the headless preview is set up for a capture. */
|
|
52
|
+
export interface IWccCaptureView {
|
|
53
|
+
/** Default `desktop`. */
|
|
54
|
+
viewport?: TWccCaptureViewport;
|
|
55
|
+
/** Height of the preview window in CSS pixels; default 800. */
|
|
56
|
+
height?: number;
|
|
57
|
+
/** Default `dark`. */
|
|
58
|
+
theme?: TWccTheme;
|
|
59
|
+
/** Device pixel ratio, 1 or 2; default 1. */
|
|
60
|
+
scale?: 1 | 2;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface IWccScreenshotRequest extends IWccCaptureView {
|
|
64
|
+
subject: IWccCaptureSubject;
|
|
65
|
+
/** Default `element` for element demos and `fullpage` for pages. */
|
|
66
|
+
framing?: TWccCaptureFraming;
|
|
67
|
+
/** Default `png`. */
|
|
68
|
+
format?: 'png' | 'jpeg';
|
|
69
|
+
/** JPEG quality from 1 to 100; default 80. */
|
|
70
|
+
quality?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface IWccScreenshotResult {
|
|
74
|
+
/** The demo the subject resolved to. */
|
|
75
|
+
selection: IWccSelection;
|
|
76
|
+
framing: TWccCaptureFraming;
|
|
77
|
+
image: IWccImage;
|
|
78
|
+
/** True when a full-page capture was cut at the maximum height. */
|
|
79
|
+
truncated: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One step of a scripted interaction. A selector is a CSS selector matched in the preview
|
|
84
|
+
* document and in every open shadow root below it; the first match in document order is used,
|
|
85
|
+
* once it is visible and enabled.
|
|
86
|
+
*/
|
|
87
|
+
export type TWccInteractionStep =
|
|
88
|
+
| { action: 'click'; selector: string }
|
|
89
|
+
| { action: 'hover'; selector: string }
|
|
90
|
+
/** Replaces the value of an input, textarea, select or contenteditable element. */
|
|
91
|
+
| { action: 'fill'; selector: string; text: string }
|
|
92
|
+
/** Presses a key (`Enter`, `Tab`, `a`, …), on the matched element when a selector is given. */
|
|
93
|
+
| { action: 'press'; key: string; selector?: string }
|
|
94
|
+
/** Waits until a match is visible, or until none is (`hidden`). */
|
|
95
|
+
| { action: 'waitFor'; selector: string; state?: 'visible' | 'hidden' }
|
|
96
|
+
/** Observes for a fixed time, for example an animation. */
|
|
97
|
+
| { action: 'wait'; ms: number };
|
|
98
|
+
|
|
99
|
+
export interface IWccInteractionRequest extends IWccCaptureView {
|
|
100
|
+
subject: IWccCaptureSubject;
|
|
101
|
+
steps: TWccInteractionStep[];
|
|
102
|
+
/** How long to keep observing after the last step, in milliseconds; default 500. */
|
|
103
|
+
observeMs?: number;
|
|
104
|
+
/** The most frames to return, spread over the observation; default 12. */
|
|
105
|
+
maxFrames?: number;
|
|
106
|
+
/** JPEG quality of the frames from 1 to 100; default 70. */
|
|
107
|
+
frameQuality?: number;
|
|
108
|
+
/** Also take a screenshot once the steps ran, framed as a screenshot request would be. */
|
|
109
|
+
finalScreenshot?: { framing?: TWccCaptureFraming; format?: 'png' | 'jpeg'; quality?: number };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface IWccInteractionStepResult {
|
|
113
|
+
index: number;
|
|
114
|
+
ok: boolean;
|
|
115
|
+
/** Why the step failed; the steps after a failed one do not run. */
|
|
116
|
+
error?: string;
|
|
117
|
+
/** Milliseconds since the start of the observation. */
|
|
118
|
+
startMs: number;
|
|
119
|
+
endMs: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface IWccInteractionResult {
|
|
123
|
+
selection: IWccSelection;
|
|
124
|
+
/** Viewport frames in time order, timestamped from the start of the observation. */
|
|
125
|
+
frames: IWccTimedFrame[];
|
|
126
|
+
steps: IWccInteractionStepResult[];
|
|
127
|
+
/** True when every step ran. */
|
|
128
|
+
completed: boolean;
|
|
129
|
+
finalImage?: IWccImage;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** How frames are sampled from a recording. */
|
|
133
|
+
export interface IWccFrameSamplingOptions {
|
|
134
|
+
/** Number of frames, spread evenly over the range; default 8, at most 32. */
|
|
135
|
+
count?: number;
|
|
136
|
+
/** Start of the range in seconds; default the start of the recording. */
|
|
137
|
+
startS?: number;
|
|
138
|
+
/** End of the range in seconds; default the end of the recording. */
|
|
139
|
+
endS?: number;
|
|
140
|
+
/** Frames wider than this are scaled down; default 960. */
|
|
141
|
+
maxWidth?: number;
|
|
142
|
+
/** JPEG quality from 0 to 1; default 0.8. */
|
|
143
|
+
quality?: number;
|
|
144
|
+
}
|
package/ts_interfaces/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
|
+
import type {
|
|
3
|
+
IWccInteractionRequest,
|
|
4
|
+
IWccInteractionResult,
|
|
5
|
+
IWccScreenshotRequest,
|
|
6
|
+
IWccScreenshotResult,
|
|
7
|
+
} from './capture.js';
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Typed requests the shell sends to the `wcctools dev` server on `/wcctools/typedrequest`.
|
|
@@ -63,3 +69,36 @@ export interface IReq_GetBundleStatus
|
|
|
63
69
|
};
|
|
64
70
|
response: IWccBundleStatusSnapshot;
|
|
65
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Screenshots one demo in the dev server's headless browser. A capture waits while a catalog
|
|
75
|
+
* bundle builds, and is refused with a typed error when a bundle failed, when the subject does not
|
|
76
|
+
* resolve to a rendered demo, when the browser is busy with other captures, when the demo
|
|
77
|
+
* navigates away from the preview, or on its deadline of 30 seconds.
|
|
78
|
+
*/
|
|
79
|
+
export interface IReq_CaptureScreenshot
|
|
80
|
+
extends plugins.typedrequestInterfaces.implementsTR<
|
|
81
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
82
|
+
IReq_CaptureScreenshot
|
|
83
|
+
> {
|
|
84
|
+
method: 'captureScreenshot';
|
|
85
|
+
request: IWccScreenshotRequest;
|
|
86
|
+
response: IWccScreenshotResult;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Renders one demo in the dev server's headless browser, runs the scripted steps on it and
|
|
91
|
+
* returns the frames it observed meanwhile. A failed step ends the script and is reported in the
|
|
92
|
+
* result, as is a step that started a navigation away from the preview, which the browser blocked;
|
|
93
|
+
* refusals are those of `captureScreenshot`. Its deadline is 135 seconds, and `TypedRequest.fire()`
|
|
94
|
+
* gives up after 60 by default: fire it with a `timeoutMs` above the deadline.
|
|
95
|
+
*/
|
|
96
|
+
export interface IReq_CaptureInteraction
|
|
97
|
+
extends plugins.typedrequestInterfaces.implementsTR<
|
|
98
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
99
|
+
IReq_CaptureInteraction
|
|
100
|
+
> {
|
|
101
|
+
method: 'captureInteraction';
|
|
102
|
+
request: IWccInteractionRequest;
|
|
103
|
+
response: IWccInteractionResult;
|
|
104
|
+
}
|
|
@@ -803,6 +803,18 @@ export class WccRecordingPanel extends DeesElement {
|
|
|
803
803
|
this.recorderService.stopRecording();
|
|
804
804
|
}
|
|
805
805
|
|
|
806
|
+
/**
|
|
807
|
+
* The finished recording shown for preview, with its trim range in seconds, or null while there
|
|
808
|
+
* is none. `sampleVideoFrames` (services/framesampler.service.ts) samples frames from it.
|
|
809
|
+
*/
|
|
810
|
+
public getRecording(): { blob: Blob; trimStartS: number; trimEndS: number } | null {
|
|
811
|
+
const blob = this.recorderService.recordedBlob;
|
|
812
|
+
if (!blob || this.panelState !== 'preview') {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
return { blob, trimStartS: this.trimStart, trimEndS: this.trimEnd };
|
|
816
|
+
}
|
|
817
|
+
|
|
806
818
|
private handleRecordingComplete(blob: Blob): void {
|
|
807
819
|
if (this.previewVideoUrl) {
|
|
808
820
|
URL.revokeObjectURL(this.previewVideoUrl);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type * as plugins from '../plugins.js';
|
|
2
|
+
|
|
3
|
+
type IWccTimedFrame = plugins.interfaces.IWccTimedFrame;
|
|
4
|
+
type IWccFrameSamplingOptions = plugins.interfaces.IWccFrameSamplingOptions;
|
|
5
|
+
|
|
6
|
+
/** The limits of a frame sampling. */
|
|
7
|
+
export const frameSamplingLimits = {
|
|
8
|
+
defaultCount: 8,
|
|
9
|
+
maxCount: 32,
|
|
10
|
+
defaultMaxWidth: 960,
|
|
11
|
+
minMaxWidth: 64,
|
|
12
|
+
maxMaxWidth: 3840,
|
|
13
|
+
defaultQuality: 0.8,
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/** Thrown when a recording cannot be sampled as asked. */
|
|
17
|
+
export class FrameSamplingError extends Error {
|
|
18
|
+
constructor(messageArg: string) {
|
|
19
|
+
super(messageArg);
|
|
20
|
+
this.name = 'FrameSamplingError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const encodeBase64 = async (blobArg: Blob): Promise<string> => {
|
|
25
|
+
const bytes = new Uint8Array(await blobArg.arrayBuffer());
|
|
26
|
+
let binary = '';
|
|
27
|
+
// In chunks: String.fromCharCode takes a bounded number of arguments
|
|
28
|
+
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
29
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
30
|
+
}
|
|
31
|
+
return btoa(binary);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const encodeJpeg = (canvasArg: HTMLCanvasElement | OffscreenCanvas, qualityArg: number): Promise<Blob> => {
|
|
35
|
+
if (canvasArg instanceof OffscreenCanvas) {
|
|
36
|
+
return canvasArg.convertToBlob({ type: 'image/jpeg', quality: qualityArg });
|
|
37
|
+
}
|
|
38
|
+
return new Promise<Blob>((resolve, reject) => {
|
|
39
|
+
canvasArg.toBlob((blobArg) => {
|
|
40
|
+
if (blobArg) {
|
|
41
|
+
resolve(blobArg);
|
|
42
|
+
} else {
|
|
43
|
+
reject(new FrameSamplingError('wcctools: a recording frame could not be encoded as JPEG.'));
|
|
44
|
+
}
|
|
45
|
+
}, 'image/jpeg', qualityArg);
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Samples timestamped JPEG frames from a recording (a WebM or MP4 blob, as the recorder produces):
|
|
51
|
+
* `count` frames at the middles of as many equal parts of the range, each the frame on screen at
|
|
52
|
+
* that time, scaled down to `maxWidth`. Frames that repeat one already sampled are left out.
|
|
53
|
+
*/
|
|
54
|
+
export const sampleVideoFrames = async (
|
|
55
|
+
recordingArg: Blob,
|
|
56
|
+
optionsArg: IWccFrameSamplingOptions = {},
|
|
57
|
+
): Promise<IWccTimedFrame[]> => {
|
|
58
|
+
const count = optionsArg.count ?? frameSamplingLimits.defaultCount;
|
|
59
|
+
if (!Number.isInteger(count) || count < 1 || count > frameSamplingLimits.maxCount) {
|
|
60
|
+
throw new FrameSamplingError(`wcctools: the frame count must be a whole number from 1 to ${frameSamplingLimits.maxCount}.`);
|
|
61
|
+
}
|
|
62
|
+
const maxWidth = optionsArg.maxWidth ?? frameSamplingLimits.defaultMaxWidth;
|
|
63
|
+
if (!Number.isInteger(maxWidth) || maxWidth < frameSamplingLimits.minMaxWidth || maxWidth > frameSamplingLimits.maxMaxWidth) {
|
|
64
|
+
throw new FrameSamplingError(`wcctools: maxWidth must be a whole number from ${frameSamplingLimits.minMaxWidth} to ${frameSamplingLimits.maxMaxWidth}.`);
|
|
65
|
+
}
|
|
66
|
+
const quality = optionsArg.quality ?? frameSamplingLimits.defaultQuality;
|
|
67
|
+
if (!(quality > 0 && quality <= 1)) {
|
|
68
|
+
throw new FrameSamplingError('wcctools: the quality must be above 0 and at most 1.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { Input, BlobSource, CanvasSink, ALL_FORMATS } = await import('mediabunny');
|
|
72
|
+
const input = new Input({ source: new BlobSource(recordingArg), formats: ALL_FORMATS });
|
|
73
|
+
try {
|
|
74
|
+
const track = await input.getPrimaryVideoTrack();
|
|
75
|
+
if (!track) {
|
|
76
|
+
throw new FrameSamplingError('wcctools: the recording has no video track.');
|
|
77
|
+
}
|
|
78
|
+
if (!(await track.canDecode())) {
|
|
79
|
+
throw new FrameSamplingError(`wcctools: this browser cannot decode the recording's ${track.codec ?? 'unknown'} video.`);
|
|
80
|
+
}
|
|
81
|
+
const firstTimestamp = await track.getFirstTimestamp();
|
|
82
|
+
const duration = await track.computeDuration();
|
|
83
|
+
const startS = Math.max(optionsArg.startS ?? firstTimestamp, firstTimestamp);
|
|
84
|
+
const endS = Math.min(optionsArg.endS ?? duration, duration);
|
|
85
|
+
if (!(endS > startS)) {
|
|
86
|
+
throw new FrameSamplingError(`wcctools: the range ${startS}–${endS} s holds no frames of the ${duration} s recording.`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const sink = new CanvasSink(track, track.displayWidth > maxWidth ? { width: maxWidth } : {});
|
|
90
|
+
const step = (endS - startS) / count;
|
|
91
|
+
const timestamps = Array.from({ length: count }, (_, indexArg) => startS + step * (indexArg + 0.5));
|
|
92
|
+
const frames: IWccTimedFrame[] = [];
|
|
93
|
+
let previousTimestamp: number | null = null;
|
|
94
|
+
for await (const wrapped of sink.canvasesAtTimestamps(timestamps)) {
|
|
95
|
+
if (!wrapped || wrapped.timestamp === previousTimestamp) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
previousTimestamp = wrapped.timestamp;
|
|
99
|
+
const jpeg = await encodeJpeg(wrapped.canvas, quality);
|
|
100
|
+
frames.push({
|
|
101
|
+
mimeType: 'image/jpeg',
|
|
102
|
+
width: wrapped.canvas.width,
|
|
103
|
+
height: wrapped.canvas.height,
|
|
104
|
+
dataBase64: await encodeBase64(jpeg),
|
|
105
|
+
timestampMs: Math.round(wrapped.timestamp * 1000),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return frames;
|
|
109
|
+
} finally {
|
|
110
|
+
input.dispose();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export const commitinfo = {
|
|
5
5
|
name: '@design.estate/wcctools',
|
|
6
|
-
version: '
|
|
6
|
+
version: '7.0.0',
|
|
7
7
|
description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
|
|
8
8
|
}
|