Update Screenci asset and overlay handling

This commit is contained in:
Olli Paloviita
2026-07-05 23:13:45 +03:00
parent bba87b8af4
commit ab3793b3bc
15 changed files with 154 additions and 41 deletions
+9
View File
@@ -948,6 +948,15 @@ describe('CLI', () => {
await main()
const messages = loggerInfoSpy.mock.calls.map((call) =>
stripVTControlCharacters(String(call[0]))
)
expect(messages).toContain(
'Recorded without an account. 3 free trial recordings left. Sign up to keep it and record without limits.'
)
expect(messages).not.toContain(
'Recorded without an account. Sign up to keep it.'
)
const anonSessionWrites = mockWriteFile.mock.calls.filter((call) =>
String(call[0]).endsWith('anon-session.json')
)
+4 -3
View File
@@ -67,7 +67,7 @@ import {
checkAnonSessionStatus,
deleteAnonSessionFile,
evaluateAnonRecordingGate,
formatAnonRecordingsLeft,
formatAnonPostRecordNotice,
formatAnonTermsNotice,
getOrCreateAnonToken,
readAnonSessionRecordUrl,
@@ -3058,7 +3058,6 @@ async function uploadRecordedVideosForConfig(
`Failed to remember anonymous recording URL: ${err instanceof Error ? err.message : String(err)}`
)
}
logger.info(`Recorded without an account. Sign up to keep it.`)
// Report how many trial recordings remain after this run. Best-effort:
// checkAnonSessionStatus never throws (it falls back on failure), so a
// transient outage just shows the optimistic remaining count.
@@ -3066,7 +3065,9 @@ async function uploadRecordedVideosForConfig(
backendUrl: apiUrl,
})
if (postStatus.status === 'pending') {
logger.info(formatAnonRecordingsLeft(postStatus.remaining))
logger.info(formatAnonPostRecordNotice(postStatus.remaining))
} else {
logger.info(`Recorded without an account. Sign up to keep it.`)
}
}
if (notices.length > 0) {
+1 -1
View File
@@ -36,7 +36,7 @@ video.overlays({ intro: selected('Intro Clip') })(
)
```
A dependency overlay is driven exactly like any other overlay: call it for a blocking window (`await overlays.intro.for('1.2s')`), hold it until a position (`await overlays.intro.until('0:05')`), let a bare `await overlays.intro()` hold it for its natural length, or drive a live window with `start()`/`end()`. It accepts the same placement options as a file overlay (`x`/`y`/`width`/`height`/`relativeTo`/`aspectRatio`/`fill`), so you can frame it anywhere in the output.
A dependency overlay is driven exactly like any other overlay: call it for a blocking window (`await overlays.intro.for('1.2s')` or `await overlays.intro.for(1200)`), hold it until a position (`await overlays.intro.until('0:05')`), let a bare `await overlays.intro()` hold it for its natural length, or drive a live window with `start()`/`end()`. It accepts the same placement options as a file overlay (`x`/`y`/`width`/`height`/`relativeTo`/`aspectRatio`/`fill`), so you can frame it anywhere in the output.
```ts
video.overlays({
+1 -1
View File
@@ -104,7 +104,7 @@ this video (matched by the overlay's name). See
Rules:
- `.tsx`, `.html`, `.svg`, and `.png` overlays need a length: give them a relative `.for('1.2s')`, an absolute `.until('0:05')`/`.until('56%')`, a `duration` config string, or drive them with `start()`/`end()`. A bare `overlays.logo()` is invalid for these (it only works for a video or render dependency, which holds for its natural length).
- `.tsx`, `.html`, `.svg`, and `.png` overlays need a length: give them a relative `.for('1.2s')` / `.for(1200)`, an absolute `.until('0:05')`/`.until('56%')`, a `duration` config string or millisecond number, or drive them with `start()`/`end()`. A bare `overlays.logo()` is invalid for these (it only works for a video or render dependency, which holds for its natural length).
- `.tsx`, `.html`, and image overlays do not support `volume`.
- `.mp4` overlays may provide `volume` (a linear gain). `1` (the default) plays the source at its natural level, `0` mutes it, and values above `1` boost it (e.g. `2` is twice as loud, up to `4`).
- `.mp4` overlays use the file's natural duration and must not provide a `duration`.
+2 -2
View File
@@ -56,7 +56,7 @@ entered:
```ts
await page.getByLabel('Password').fill('hunter2', { redact: true })
await page.getByLabel('API key').fill(apiKey, {
redact: { style: { color: 'black' } },
redact: { style: { color: '#fff3d6', radius: 10 } },
})
```
@@ -80,7 +80,7 @@ default it samples its color from the surface underneath. Override it with
`RedactOptions.style`:
```ts
await redact(page.locator('.ssn'), { style: { color: '#111', radius: 6 } })
await redact(page.locator('.ssn'), { style: { color: '#fff3d6', radius: 10 } })
await redact(page.locator('.token'), {
style: {
css: 'background: repeating-linear-gradient(45deg,#222 0 6px,#333 6px 12px)',
+5 -5
View File
@@ -196,11 +196,11 @@ await overlays.tip.until('2s') // seconds (fractions allowed: '5.51s')
Accepted forms for `.until(...)`: `'<n>s'` seconds, `'m:ss(.f)'` /
`'h:mm:ss(.f)'` timecodes, and `'<n>%'` percentages. For a relative length
instead, use `.for('<n>s')`. Positions are resolved against the finished render, so they are correct
against the actual video, and narration audio is never cut (the window extends to
let a line finish). Percentages are not supported on `.mp4` or animated overlays,
whose length is fixed. See [Narration](/docs/guides/narration) and
[Overlays](/docs/guides/overlays).
instead, use `.for('<n>s')` or `.for(<ms>)`. Positions are resolved against the
finished render, so they are correct against the actual video, and narration
audio is never cut (the window extends to let a line finish). Percentages are
not supported on `.mp4` or animated overlays, whose length is fixed. See
[Narration](/docs/guides/narration) and [Overlays](/docs/guides/overlays).
### `autoZoom()`
+1
View File
@@ -90,6 +90,7 @@ export { MAX_AUDIO_LEVEL, selected } from './src/asset.js'
export type {
OverlayController,
OverlayConfig,
OverlayDuration,
TsxOverlayConfig,
HtmlPageOverlayConfig,
MediaOverlayConfig,
+21
View File
@@ -10,6 +10,7 @@ import {
checkAnonSessionStatus,
deleteAnonSessionFile,
evaluateAnonRecordingGate,
formatAnonPostRecordNotice,
formatAnonRecordingsLeft,
formatAnonTermsNotice,
getOrCreateAnonToken,
@@ -211,6 +212,26 @@ describe('formatAnonRecordingsLeft', () => {
})
})
describe('formatAnonPostRecordNotice', () => {
it('combines anonymous account and plural remaining recording notices', () => {
expect(formatAnonPostRecordNotice(2)).toBe(
'Recorded without an account. 2 free trial recordings left. Sign up to keep it and record without limits.'
)
})
it('uses the singular for exactly one remaining recording', () => {
expect(formatAnonPostRecordNotice(1)).toBe(
'Recorded without an account. 1 free trial recording left. Sign up to keep it and record without limits.'
)
})
it('combines anonymous account and final recording notices', () => {
expect(formatAnonPostRecordNotice(0)).toBe(
'Recorded without an account. That was your last free trial recording. Sign up to keep it and record more.'
)
})
})
describe('credential helpers', () => {
it('secretCredential attaches the secret header', () => {
expect(secretCredential('sec_123')).toEqual({
+10
View File
@@ -150,6 +150,16 @@ export function formatAnonRecordingsLeft(remaining: number): string {
} left. Sign up to record without limits.`
}
/** A single-line notice shown after an anonymous recording succeeds. */
export function formatAnonPostRecordNotice(remaining: number): string {
if (remaining <= 0) {
return 'Recorded without an account. That was your last free trial recording. Sign up to keep it and record more.'
}
return `Recorded without an account. ${remaining} free trial recording${
remaining === 1 ? '' : 's'
} left. Sign up to keep it and record without limits.`
}
export type AnonSessionStatus =
| { status: 'not_found' }
| { status: 'expired' }
+43
View File
@@ -110,6 +110,21 @@ describe('createOverlays', () => {
})
})
it('accepts config duration as numeric milliseconds', async () => {
const overlays = createOverlays({
logo: { path: './logo.png', duration: 1200 },
})
await overlays.logo()
expect(recorder.addAssetStart).toHaveBeenCalledWith('logo', {
kind: 'image',
path: './logo.png',
durationMs: 1200,
fullScreen: false,
})
})
it('records an absolute string position as an outputMs anchor', async () => {
const overlays = createOverlays({ logo: { path: './logo.png' } })
@@ -1312,6 +1327,21 @@ describe('selected (render dependency overlays)', () => {
})
})
it('accepts dependency config duration as numeric milliseconds', async () => {
const overlays = createOverlays({
intro: selected('Intro Clip', { duration: 800 }),
})
await overlays.intro()
expect(recorder.addAssetStart).toHaveBeenCalledWith('intro', {
kind: 'dependency',
dependency: { name: 'Intro Clip' },
durationMs: 800,
fullScreen: false,
})
})
it('records a bare dependency call with its natural length (no durationMs)', async () => {
const overlays = createOverlays({ intro: selected('Intro Clip') })
@@ -1494,6 +1524,19 @@ describe('overlay length API (.for / .until)', () => {
})
})
it('.for(1200) records numeric milliseconds on the asset start', async () => {
const overlays = createOverlays({ logo: { path: './logo.png' } })
await overlays.logo.for(1200)
expect(recorder.addAssetStart).toHaveBeenCalledWith('logo', {
kind: 'image',
path: './logo.png',
durationMs: 1200,
fullScreen: false,
})
})
it(".for('56%') rejects a percentage (a relative length has no percentage)", () => {
const overlays = createOverlays({ logo: { path: './logo.png' } })
+10 -3
View File
@@ -10,7 +10,7 @@ describe('createOverlays type constraints', () => {
createOverlays({
logo: {
path: './logo.png',
durationMs: 1200,
duration: 1200,
x: 200,
y: 120,
width: 600,
@@ -20,7 +20,7 @@ describe('createOverlays type constraints', () => {
it('accepts an .html page overlay', () => {
createOverlays({
note: { path: './note.html', durationMs: 1200, x: 1340, width: 380 },
note: { path: './note.html', duration: 1200, x: 1340, width: 380 },
})
})
@@ -127,10 +127,17 @@ describe('createOverlays type constraints', () => {
it('rejects calling a static-key controller with props', () => {
const overlays = createOverlays({ logo: './logo.png' })
// @ts-expect-error a static overlay controller takes a durationMs, not props
overlays.logo.for(1200)
// @ts-expect-error a static overlay controller does not take props
overlays.logo({ text: 'hi' })
})
it('rejects numeric positions for until', () => {
const overlays = createOverlays({ logo: './logo.png' })
// @ts-expect-error until takes a string timeline position, not a duration
overlays.logo.until(1200)
})
it('rejects calling a factory-key controller without props', () => {
const overlays = createOverlays({
note: (p: { x: number }) => ({ path: './note.html', x: p.x }),
+39 -23
View File
@@ -33,6 +33,9 @@ import {
export type { OverlayPlacement, OverlayCrop } from './events.js'
/** A relative overlay length: a time string (`'2s'`, `'0:02'`) or milliseconds. */
export type OverlayDuration = TimelineOffset | number
/**
* Placement and capture fields shared by every overlay variant. Placement is
* flat (not nested) and uses CSS pixels in the recording viewport (the same
@@ -81,16 +84,17 @@ type OverlayCommon = {
*/
margin?: number
/**
* Default visible length, as a relative time string (`'2s'`, `'0:02'`), used
* when the overlay is shown with a bare call (`await overlays.logo()`) or
* `.for()` without its own length. Seconds/timecode only (no percentage).
* Omit when driving with `start()`/`end()`. Image/HTML/React overlays only.
* Default visible length, as a relative time string (`'2s'`, `'0:02'`) or a
* number of milliseconds, used when the overlay is shown with a bare call
* (`await overlays.logo()`) or `.for()` without its own length.
* Seconds/timecode only (no percentage). Omit when driving with
* `start()`/`end()`. Image/HTML/React overlays only.
*
* For animated overlays (`animate: true`) this is also the capture length: it
* is required when driving with `start()`/`end()` (the capture length is
* otherwise unknown).
*/
duration?: TimelineOffset
duration?: OverlayDuration
/**
* Capture the overlay as an animation so its CSS/JS animation plays back in
* the video (`.html`/`.tsx` page overlays only). The animation is sampled over
@@ -576,12 +580,12 @@ export type OverlayController = {
*/
(): Promise<void>
/**
* Hold the overlay for a relative length, e.g. `.for('2s')` or `.for('0:02')`.
* Seconds and timecodes only; a percentage is rejected (a relative length has
* nothing to take a percentage of). Not for `.mp4`/animated overlays, whose
* length is fixed.
* Hold the overlay for a relative length, e.g. `.for('2s')`, `.for('0:02')`,
* or `.for(2000)`. String seconds/timecodes and numeric milliseconds are
* accepted; a percentage is rejected (a relative length has nothing to take a
* percentage of). Not for `.mp4`/animated overlays, whose length is fixed.
*/
for(duration: TimelineOffset): Promise<void>
for(duration: OverlayDuration): Promise<void>
/**
* Keep the overlay visible until this absolute point in the final video (a
* `'<n>s'`/timecode position, or a `'<n>%'` fraction). Supported for image,
@@ -627,8 +631,8 @@ export type Overlays<T extends Record<string, OverlayInputOrFactory>> = {
* `element`, or an inline `html` fragment.
*
* Calling a controller shows the overlay in the recording timeline. Image
* (`.svg`/`.png`), HTML, and React overlays need a `durationMs` (in the config
* or passed to the blocking call) unless driven with `start()`/`end()`; `.mp4`
* (`.svg`/`.png`), HTML, and React overlays need a `duration` (in the config
* or passed to `.for(...)`) unless driven with `start()`/`end()`; `.mp4`
* overlays use their natural duration and default `audio` to `1` (natural level).
*
* Placement defaults to the full recording area (`relativeTo: 'recording'`);
@@ -648,13 +652,13 @@ export type Overlays<T extends Record<string, OverlayInputOrFactory>> = {
* video('Product demo', async ({ page }) => {
* await overlays.intro()
* await page.goto('/dashboard')
* await overlays.logo(1200)
* await overlays.logo.for(1200)
* })
* ```
*
* A value can also be an {@link OverlayConfigFactory} `(props) => OverlayConfig`,
* making the overlay programmatic. Calling `overlays.name(props)` builds and
* returns a controller you then drive with `(durationMs)`, `start()`, or
* returns a controller you then drive with `.for(...)`, `start()`, or
* `end()`. The factory runs (and its config is validated) on each call, so
* content and placement can depend on runtime values.
*/
@@ -970,14 +974,26 @@ function resolveOverlayAnchor(until: TimelineOffset): TimelineAnchorInput {
/**
* Resolves a relative length string (`.for('2s')` or a config `duration`) into
* milliseconds. Seconds and timecodes only: a percentage is rejected because a
* relative length has nothing to take a percentage of (use `.until('<n>%')` for
* an absolute position instead).
* milliseconds. Numbers are already milliseconds. String seconds and timecodes
* are accepted; a percentage is rejected because a relative length has nothing
* to take a percentage of (use `.until('<n>%')` for an absolute position
* instead).
*/
function resolveRelativeDuration(value: TimelineOffset, label: string): number {
function resolveRelativeDuration(
value: OverlayDuration,
label: string
): number {
if (typeof value === 'number') {
if (!isFiniteNonNegative(value)) {
throw new Error(
`[screenci] ${label} must be a finite number of milliseconds greater than or equal to 0. Received: ${String(value)}.`
)
}
return value
}
if (typeof value !== 'string') {
throw new Error(
`[screenci] ${label} must be a time string such as '2s' or '0:02', got ${typeof value}.`
`[screenci] ${label} must be a time string such as '2s' or '0:02', or a number of milliseconds, got ${typeof value}.`
)
}
const parsed = parseTimelineOffset(value)
@@ -989,10 +1005,10 @@ function resolveRelativeDuration(value: TimelineOffset, label: string): number {
return parsed.ms
}
/** Parses an optional config `duration` (a relative time string) into ms. */
/** Parses an optional config `duration` (relative time string or ms number) into ms. */
function resolveConfigDuration(
name: string,
duration: TimelineOffset | undefined
duration: OverlayDuration | undefined
): number | undefined {
if (duration === undefined) return undefined
return resolveRelativeDuration(duration, `Overlay "${name}" duration`)
@@ -1085,7 +1101,7 @@ function createAssetControllerCore(
await runBlocking({ type: 'blocking' })
}) as OverlayController
controller.for = (duration: TimelineOffset): Promise<void> =>
controller.for = (duration: OverlayDuration): Promise<void> =>
runBlocking({
type: 'blocking',
durationMs: resolveRelativeDuration(
@@ -1324,7 +1340,7 @@ function createRenderedOverlayController(
/**
* An animated overlay rendered to a transparent clip, from either an HTML file
* or a React element. The capture length is resolved from the call argument or
* config `durationMs`; `start()`/`end()` requires a config `durationMs` (the
* config `duration`; `start()`/`end()` requires a config `duration` (the
* capture length is otherwise unknown).
*
* Markup and duration are captured during the test; the clip itself is encoded
+3
View File
@@ -29,6 +29,9 @@ describe('buildClientOverlayDocument', () => {
// The document embeds the bundle and provides the empty overlay root.
expect(doc).toContain('BUNDLE_OUTPUT')
expect(doc).toContain('id="screenci-overlay-root"')
expect(doc).toContain('"Noto Sans CJK SC"')
expect(doc).toContain('"Noto Sans CJK JP"')
expect(doc).toContain('"Noto Sans Devanagari"')
expect(doc.startsWith('<!doctype html>')).toBe(true)
})
+4 -2
View File
@@ -1,6 +1,8 @@
import { dirname } from 'path'
const OVERLAY_ROOT_ID = 'screenci-overlay-root'
const OVERLAY_FONT_STACK =
'"Noto Sans", "Noto Sans CJK SC", "Noto Sans CJK JP", "Noto Sans Devanagari", "Noto Color Emoji", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
/**
* Bundles a `.tsx` page overlay: an author React component module is bundled for
@@ -112,8 +114,8 @@ export async function buildClientOverlayDocument(
})
return (
'<!doctype html><html><head><meta charset="utf-8"><style>' +
'html,body{margin:0;padding:0;background:transparent}' +
`#${OVERLAY_ROOT_ID}{display:inline-block}` +
`html,body{margin:0;padding:0;background:transparent;font-family:${OVERLAY_FONT_STACK}}` +
`#${OVERLAY_ROOT_ID}{display:inline-block;font-family:inherit}` +
`</style></head><body><div id="${OVERLAY_ROOT_ID}"></div>` +
`<script>${script}</script></body></html>`
)
+1 -1
View File
@@ -633,7 +633,7 @@ export type ScreenCILocatorPostClickMoveOptions = CursorMoveTimingOption & {
*/
export type RedactStyle = {
/**
* Opaque fill color, e.g. '#111' or 'black'. Omit to sample a color from the
* Opaque fill color, e.g. '#fff3d6'. Omit to sample a color from the
* surface underneath the element.
*/
color?: string