mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
Update Screenci asset and overlay handling
This commit is contained in:
@@ -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')
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)',
|
||||
|
||||
@@ -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()`
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export { MAX_AUDIO_LEVEL, selected } from './src/asset.js'
|
||||
export type {
|
||||
OverlayController,
|
||||
OverlayConfig,
|
||||
OverlayDuration,
|
||||
TsxOverlayConfig,
|
||||
HtmlPageOverlayConfig,
|
||||
MediaOverlayConfig,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user