feat(screenci): add studio support

This commit is contained in:
Olli Paloviita
2026-06-12 21:59:19 +03:00
parent 23d5fdbd7e
commit 504ed6c788
26 changed files with 1111 additions and 119 deletions
+113 -1
View File
@@ -496,11 +496,29 @@ describe('CLI', () => {
SCREENCI_RECORDING: 'true',
VITE_APP_BASE_URL: 'https://example.com',
}),
stdio: 'inherit',
stdio: ['inherit', 'pipe', 'inherit'],
})
)
})
it('removes Playwright HTML reporter duplicate trailing blank line', async () => {
const { createPlaywrightStdoutForwarder } = await import('./cli')
const writes: string[] = []
const forwarder = createPlaywrightStdoutForwarder((chunk) => {
writes.push(chunk)
})
forwarder.write(
'\nTo open last HTML report run:\n\u001B[36m\n pnpm exec playwright show-report\n\u001B[39m\n\n'
)
forwarder.write('Asset uploaded: ./assets/brand-badge.svg\n')
forwarder.end()
expect(stripVTControlCharacters(writes.join(''))).toBe(
'\nTo open last HTML report run:\n\n pnpm exec playwright show-report\n\nAsset uploaded: ./assets/brand-badge.svg\n'
)
})
it('should only log the config path in verbose mode', async () => {
process.argv = ['node', 'cli.js', 'record', '--verbose']
process.env.VITE_APP_BASE_URL = 'https://example.com'
@@ -602,6 +620,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
studioNotices: [],
failedVideoNames: [],
failedVideoMessages: [],
})
@@ -612,6 +631,91 @@ describe('CLI', () => {
)
})
it('surfaces studio hold and override notices from the upload start response', async () => {
mockReaddir.mockResolvedValue(['demo-video'])
mockReadFile.mockImplementation(async (path: string | URL) => {
const pathString = String(path)
if (pathString.endsWith('package.json')) {
return JSON.stringify({ version: '0.0.32' })
}
if (pathString.endsWith('data.json')) {
return JSON.stringify({ events: [], metadata: { videoName: 'Demo' } })
}
return ''
})
mockExistsSync.mockImplementation(
(path: string) =>
path.endsWith('data.json') || path.endsWith('recording.mp4')
)
mockFetch.mockImplementation(async (input: string | URL) => {
const url = String(input)
if (url.endsWith('/cli/upload/start')) {
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
recordingId: 'recording_123',
projectId: 'project_123',
videoId: 'video_123',
studio: { held: true },
}),
text: vi.fn().mockResolvedValue(''),
}
}
return {
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({}),
text: vi.fn().mockResolvedValue(''),
}
})
const { uploadRecordings } = await import('./cli')
const result = await uploadRecordings(
'/repo/.screenci',
'Test Project',
'https://api.screenci.test',
'test-secret'
)
expect(result.studioNotices).toEqual([
{
videoName: 'Demo',
videoId: 'video_123',
studio: { held: true },
},
])
})
it('formats studio change summaries and studio URLs', async () => {
const { formatStudioChangeSummary, formatStudioUrl } =
await import('./cli')
expect(
formatStudioChangeSummary([
{
kind: 'renderOption',
label: 'recording.size',
from: '1',
to: '0.8',
},
{
kind: 'narration',
label: 'narration "intro"',
cue: 'intro',
language: 'en',
},
{ kind: 'renderOption', label: 'output.quality' },
])
).toBe('recording.size (1 → 0.8), narration "intro" (en), output.quality')
expect(
formatStudioUrl('https://app.screenci.test', 'project_1', 'video_2')
).toBe('https://app.screenci.test/project/project_1/video/video_2/studio')
})
it('forwards ELEVENLABS_API_KEY during upload requests when configured', async () => {
process.env.ELEVENLABS_API_KEY = 'elevenlabs-byok-key'
mockReaddir.mockResolvedValue(['demo-video'])
@@ -842,6 +946,7 @@ describe('CLI', () => {
projectId: null,
recordId: null,
hadFailures: false,
studioNotices: [],
failedVideoNames: [],
failedVideoMessages: [],
})
@@ -877,6 +982,7 @@ describe('CLI', () => {
projectId: null,
recordId: expect.any(String),
hadFailures: true,
studioNotices: [],
failedVideoNames: ['Demo'],
failedVideoMessages: [
{
@@ -975,6 +1081,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: true,
studioNotices: [],
failedVideoNames: ['Demo'],
failedVideoMessages: [
{
@@ -1092,6 +1199,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
studioNotices: [],
failedVideoNames: [],
failedVideoMessages: [],
})
@@ -1183,6 +1291,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: true,
studioNotices: [],
failedVideoNames: ['Failed Demo'],
failedVideoMessages: [
{
@@ -1253,6 +1362,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
studioNotices: [],
failedVideoNames: [],
failedVideoMessages: [],
})
@@ -1323,6 +1433,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
studioNotices: [],
failedVideoNames: [],
failedVideoMessages: [],
})
@@ -1424,6 +1535,7 @@ describe('CLI', () => {
projectId: 'project_123',
recordId: expect.any(String),
hadFailures: false,
studioNotices: [],
failedVideoNames: [],
failedVideoMessages: [],
})
+180 -2
View File
@@ -14,6 +14,7 @@ import { createRequire } from 'module'
import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises'
import { delimiter, dirname, relative as pathRelative, resolve } from 'path'
import { fileURLToPath, pathToFileURL } from 'url'
import { stripVTControlCharacters } from 'util'
import { Command, CommanderError } from 'commander'
import pc from 'picocolors'
import { logger } from './src/logger.js'
@@ -364,12 +365,63 @@ type UploadCandidate = {
preparedUploadAssets: PreparedUploadAsset[]
}
export type StudioAppliedChange = {
kind?: string
label?: string
cue?: string
language?: string
from?: string
to?: string
}
export type UploadStudioInfo =
| { held: true }
| { appliedChanges: StudioAppliedChange[] }
export type StudioUploadNotice = {
videoName: string
videoId: string | null
studio: UploadStudioInfo
}
/**
* One-line summary of Studio overrides for CLI output, e.g.
* `recording.size (1 → 0.8), narration "intro" (en)`.
*/
export function formatStudioChangeSummary(
changes: StudioAppliedChange[]
): string {
return changes
.map((change) => {
const label = change.label ?? 'selection'
if (change.kind === 'narration') {
return change.language !== undefined
? `${label} (${change.language})`
: label
}
return change.from !== undefined && change.to !== undefined
? `${label} (${change.from}${change.to})`
: label
})
.join(', ')
}
export function formatStudioUrl(
appUrl: string,
projectId: string,
videoId: string
): string {
return `${appUrl}/project/${projectId}/video/${videoId}/studio`
}
type UploadJobResult = {
projectId: string | null
videoId: string | null
hadFailure: boolean
videoName: string
failureMessage?: string
recordId: string
studio?: UploadStudioInfo
}
type UploadProgressStatus = 'success' | 'failure' | 'cancelled'
@@ -577,6 +629,7 @@ async function uploadRecordingCandidate(
): Promise<UploadJobResult> {
const { entry, videoName, data, preparedUploadAssets } = candidate
let projectId: string | null = null
let videoId: string | null = null
try {
uploadAbort.throwIfAborted()
@@ -585,6 +638,7 @@ async function uploadRecordingCandidate(
progressReporter.complete(progressIndex, 'failure')
return {
projectId: null,
videoId: null,
hadFailure: true,
videoName,
failureMessage: `Missing recording.mp4 for "${videoName}"`,
@@ -626,6 +680,7 @@ async function uploadRecordingCandidate(
progressReporter.complete(progressIndex, 'failure')
return {
projectId: null,
videoId: null,
hadFailure: true,
videoName,
failureMessage: formatUploadStartFailureMessage(
@@ -641,9 +696,13 @@ async function uploadRecordingCandidate(
const startBody = (await startResponse.json()) as {
recordingId: string
projectId: string
videoId?: string
studio?: UploadStudioInfo
}
const { recordingId } = startBody
projectId = startBody.projectId
videoId = startBody.videoId ?? null
const studio = startBody.studio
if (verbose) {
logger.info(`recordingId=${recordingId} projectId=${projectId}`)
@@ -702,6 +761,7 @@ async function uploadRecordingCandidate(
progressReporter.complete(progressIndex, 'failure')
return {
projectId,
videoId,
hadFailure: true,
videoName,
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
@@ -714,7 +774,14 @@ async function uploadRecordingCandidate(
progressReporter.complete(progressIndex, 'success')
cleanupUploadedRecordingDir(screenciDir, entry)
return { projectId, hadFailure: false, videoName, recordId }
return {
projectId,
videoId,
hadFailure: false,
videoName,
recordId,
...(studio !== undefined && { studio }),
}
} catch (err) {
if (isUploadCancelledError(err)) {
progressReporter.complete(progressIndex, 'cancelled')
@@ -725,6 +792,7 @@ async function uploadRecordingCandidate(
progressReporter.complete(progressIndex, 'failure')
return {
projectId,
videoId,
hadFailure: true,
videoName,
failureMessage: err instanceof Error ? err.message : String(err),
@@ -735,6 +803,7 @@ async function uploadRecordingCandidate(
progressReporter.complete(progressIndex, 'failure')
return {
projectId,
videoId,
hadFailure: true,
videoName,
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
@@ -895,6 +964,57 @@ function resolvePlaywrightSpawnSpec(
}
}
export function createPlaywrightStdoutForwarder(
write: (chunk: string) => void
): {
write: (chunk: string | Buffer) => void
end: () => void
} {
let buffered = ''
let suppressNextBlankLine = false
const forwardLine = (line: string) => {
const visibleLine = stripVTControlCharacters(
line.replace(/(?:\r\n|\n|\r)$/, '')
)
if (suppressNextBlankLine && visibleLine.trim() === '') {
const lineWithoutEnding = line.replace(/(?:\r\n|\n|\r)$/, '')
if (lineWithoutEnding !== '') write(lineWithoutEnding)
suppressNextBlankLine = false
return
}
write(line)
if (visibleLine.includes('playwright show-report')) {
suppressNextBlankLine = true
} else if (visibleLine.trim() !== '') {
suppressNextBlankLine = false
}
}
return {
write(chunk) {
buffered += chunk.toString()
let lineEnd = buffered.search(/\r\n|\n|\r/)
while (lineEnd !== -1) {
const delimiterLength =
buffered[lineEnd] === '\r' && buffered[lineEnd + 1] === '\n' ? 2 : 1
const completeLine = buffered.slice(0, lineEnd + delimiterLength)
buffered = buffered.slice(lineEnd + delimiterLength)
forwardLine(completeLine)
lineEnd = buffered.search(/\r\n|\n|\r/)
}
},
end() {
if (buffered !== '') {
forwardLine(buffered)
buffered = ''
}
},
}
}
function forwardChildSignals(
child: ChildProcess,
activityLabel: string,
@@ -1468,6 +1588,7 @@ export async function uploadRecordings(
hadFailures: boolean
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
studioNotices: StudioUploadNotice[]
}> {
const uploadAbort = createUploadAbortController('upload')
const recordId = randomUUID()
@@ -1482,6 +1603,7 @@ export async function uploadRecordings(
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
studioNotices: [],
}
}
@@ -1509,6 +1631,7 @@ export async function uploadRecordings(
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
studioNotices: [],
}
}
@@ -1548,12 +1671,25 @@ export async function uploadRecordings(
: []
)
const studioNotices = results.flatMap((result) =>
!result.hadFailure && result.studio !== undefined
? [
{
videoName: result.videoName,
videoId: result.videoId,
studio: result.studio,
},
]
: []
)
return {
projectId: firstProjectId,
recordId,
hadFailures,
failedVideoNames,
failedVideoMessages,
studioNotices,
}
} finally {
uploadAbort.cleanup()
@@ -2291,12 +2427,14 @@ export async function main() {
hadFailures: boolean
failedVideoNames: string[]
failedVideoMessages: Array<{ videoName: string; message: string }>
studioNotices: StudioUploadNotice[]
} = {
projectId: null,
recordId: null,
hadFailures: false,
failedVideoNames: [],
failedVideoMessages: [],
studioNotices: [],
}
try {
uploadResult = await uploadRecordings(
@@ -2317,6 +2455,7 @@ export async function main() {
hadFailures,
failedVideoNames,
failedVideoMessages,
studioNotices,
} = uploadResult
if (recordId !== null && projectId !== null) {
const recordUrl = `${appUrl}/record/${recordId}`
@@ -2339,6 +2478,24 @@ export async function main() {
)
logger.info(pc.cyan(projectUrl))
}
for (const notice of studioNotices) {
if ('held' in notice.studio) {
logger.info('')
logger.info(
`Rendering for "${notice.videoName}" is on hold — configure it in Studio:`
)
if (projectId !== null && notice.videoId !== null) {
logger.info(
pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId))
)
}
} else if (notice.studio.appliedChanges.length > 0) {
logger.info('')
logger.info(
`Selections were overridden in Studio for "${notice.videoName}": ${formatStudioChangeSummary(notice.studio.appliedChanges)}`
)
}
}
if (hadFailures) {
for (const failedVideo of failedVideoMessages) {
logger.warn(
@@ -2669,8 +2826,17 @@ async function run(
playwrightArgs,
dirname(configPath)
)
const forwardRecordStdout =
command === 'record' && process.env.SCREENCI_RECORDING !== 'true'
const playwrightTTY =
process.stdout.isTTY === true &&
process.env.PLAYWRIGHT_FORCE_TTY === undefined
? process.stdout.columns && process.stdout.rows
? `${process.stdout.columns}x${process.stdout.rows}`
: 'true'
: undefined
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: 'inherit',
stdio: forwardRecordStdout ? ['inherit', 'pipe', 'inherit'] : 'inherit',
...(process.platform !== 'win32' ? { detached: true } : {}),
...(spawnSpec.shell !== undefined ? { shell: spawnSpec.shell } : {}),
...(spawnSpec.windowsVerbatimArguments !== undefined
@@ -2689,8 +2855,19 @@ async function run(
...(command === 'test' && mockRecord
? { [SCREENCI_MOCK_RECORD_ENV]: 'true' }
: {}),
...(playwrightTTY !== undefined
? { PLAYWRIGHT_FORCE_TTY: playwrightTTY }
: {}),
},
})
const stdoutForwarder = forwardRecordStdout
? createPlaywrightStdoutForwarder((chunk) => {
process.stdout.write(chunk)
})
: null
child.stdout?.on('data', (chunk: string | Buffer) => {
stdoutForwarder?.write(chunk)
})
const childSignals = forwardChildSignals(child, `screenci ${command}`, {
killTree: process.platform !== 'win32',
exitParentOnForward: true,
@@ -2699,6 +2876,7 @@ async function run(
return new Promise<void>((resolve, reject) => {
child.on('close', (code, signal) => {
void (async () => {
stdoutForwarder?.end()
const forwardedSignal = childSignals.getForwardedSignal()
childSignals.cleanup()
+5 -1
View File
@@ -146,6 +146,10 @@ Set shared `renderOptions` under `use` when you want consistent output styling:
Use project-wide render defaults for branding and layout consistency, then
override only the files that need a different look.
On the Business tier you can defer render options to the web app entirely by
setting `renderOptions: STUDIO_RENDER_OPTIONS` — they are then managed on the
Studio page. See [Studio](/docs/guides/studio).
### Example: shared `use` defaults
Use `use` when multiple videos should share the same recording, navigation, or
@@ -260,5 +264,5 @@ real workflow.
## Related pages
- [CLI](/docs/reference/cli) for how config is discovered and loaded.
- [Narration and Localization](/docs/narration-and-localization) for cue and
- [Narration and Localization](/docs/guides/narration-and-localization) for cue and
voice authoring.
+1 -1
View File
@@ -217,5 +217,5 @@ It should look something like this:
- [Public URLs and Embeds](/docs/guides/public-urls-and-embeds) to publish a
stable URL for documentation or websites that can automatically serve the
latest selected video version.
- [Narration and Localization](/docs/narration-and-localization) to add and
- [Narration and Localization](/docs/guides/narration-and-localization) to add and
manage language versions.
+18 -6
View File
@@ -98,25 +98,37 @@ export const docsManifest = [
description:
'Add intro clips, corner logos, transition assets, and timed overlays to ScreenCI recordings.',
prev: 'docs/guides/camera-and-zooming',
next: 'docs/guides/studio',
},
{
source: 'studio.md',
slug: 'docs/guides/studio',
section: 'Guides',
order: 5,
navLabel: 'Studio',
title: 'Studio',
description:
'Remix render options, narration text, and voices from the web app, or manage them entirely in Studio with createStudioNarration. Business tier.',
prev: 'docs/guides/assets-and-overlays',
next: 'docs/guides/public-urls-and-embeds',
},
{
source: 'public-urls-and-embeds.md',
slug: 'docs/guides/public-urls-and-embeds',
section: 'Guides',
order: 5,
order: 6,
navLabel: 'Public URLs and embeds',
title: 'Public URLs and Embeds',
description:
'Enable public delivery for a video, understand stable language-specific URLs, and embed ScreenCI outputs in other sites.',
prev: 'docs/guides/assets-and-overlays',
prev: 'docs/guides/studio',
next: 'docs/guides/update-screenci',
},
{
source: 'update-screenci.mdx',
slug: 'docs/guides/update-screenci',
section: 'Guides',
order: 6,
order: 7,
navLabel: 'Update ScreenCI',
title: 'Update ScreenCI',
description:
@@ -128,7 +140,7 @@ export const docsManifest = [
source: 'cli.mdx',
slug: 'docs/reference/cli',
section: 'Guides',
order: 7,
order: 8,
navLabel: 'CLI',
title: 'CLI',
description:
@@ -140,7 +152,7 @@ export const docsManifest = [
source: 'configuration.md',
slug: 'docs/reference/configuration',
section: 'Guides',
order: 8,
order: 9,
navLabel: 'Configuration',
title: 'Configuration',
description:
@@ -152,7 +164,7 @@ export const docsManifest = [
source: 'public-delivery-api.md',
slug: 'docs/reference/public-delivery-api',
section: 'Guides',
order: 9,
order: 10,
navLabel: 'Public delivery API',
title: 'Public Delivery API',
description:
+54 -8
View File
@@ -278,13 +278,21 @@ Use `modelType` when you need to choose between consistency and expressiveness.
- `expressive` is useful when you want a more natural, less uniform delivery
- `expressive` and `style` prompts require the Business tier
## ElevenLabs BYOK and cost control
## ElevenLabs voices
If your narration setup uses ElevenLabs-backed voices, treat that as BYOK.
Keep `ELEVENLABS_API_KEY` in your configured `envFile` or project `.env`; see
[Configuration](/docs/reference/configuration). ScreenCI loads that file
automatically for local commands, and ScreenCI does not store the raw
ElevenLabs API key.
ElevenLabs voices require the ScreenCI Business tier and use your own
ElevenLabs API key. Keep `ELEVENLABS_API_KEY` in your configured `envFile` or
project `.env`; see [Configuration](/docs/reference/configuration). ScreenCI
loads that file automatically for local commands, and ScreenCI does not store
the raw API key or use it for anything except synthesizing narration for your
videos.
For example, your project `.env` can contain:
```dotenv
SCREENCI_SECRET=added_by_npx_screenci_record
ELEVENLABS_API_KEY=your_elevenlabs_api_key
```
Use `voices.elevenlabs({ voiceId })` when you want to target a specific
ElevenLabs voice from your own account:
@@ -315,6 +323,44 @@ video('Billing walkthrough', async ({ page }) => {
Replace the `voiceId` with the voice from your ElevenLabs account. For the
env-file setup, see [Configuration](/docs/reference/configuration).
ScreenCI supports the ElevenLabs `eleven_multilingual_v2` model only. Its
supported per-voice controls are `stability` (`0``1`), `similarityBoost`
(`0``1`), numeric `style` exaggeration (`0``1`), `speed` (`0.7``1.2`),
and `useSpeakerBoost`. These fields are accepted only for
`voices.elevenlabs(...)` and custom cloned voices:
```ts
const narration = createNarration({
voice: {
name: voices.elevenlabs({ voiceId: 'tMvyQtpCVQ0DkixuYm6J' }),
stability: 0.45,
similarityBoost: 0.8,
style: 0.2,
speed: 0.9,
useSpeakerBoost: true,
},
en: {
intro: 'Welcome to the dashboard.',
},
})
```
See the ElevenLabs
[Create speech with timing API](https://elevenlabs.io/docs/api-reference/text-to-speech/convert-with-timestamps)
for the upstream request fields. ElevenLabs `style` is a numeric exaggeration
control; it is not the free-form style prompt available to ScreenCI expressive
model voices.
Author cues sparingly, one sentence at a time, instead of large paragraphs.
That keeps the timeline easier to control and should reduce unnecessary
synthesis/API cost when you revise only part of the script.
ScreenCI also uses the ElevenLabs API sparingly: generated narration is cached
per cue, so unchanged cues are reused and only changed narration is synthesized
again. Smaller cues keep the timeline easier to control and further reduce API
cost when you revise only part of the script.
## Manage narration from Studio
On the Business tier you can manage narration from the web app instead of
code: declare cue keys with `createStudioNarration('intro', 'outro')` and fill
in text, languages, and voices on the Studio page. Existing
`createNarration` videos can also be remixed in Studio without code changes.
See [Studio](/docs/guides/studio).
+107
View File
@@ -0,0 +1,107 @@
# Studio
Studio lets your team remix videos from the ScreenCI web app — change render
options, narration text, and voices without touching code or re-running the
recording. Studio is available on the Business tier.
There are two ways to use it:
- **Remix an existing video.** Any video recorded with `createNarration` can
be remixed in Studio. Your code-specified values are prefilled; override
them and render a new version server-side.
- **Opt in from code.** Declare cue keys with `createStudioNarration` and set
`renderOptions: STUDIO_RENDER_OPTIONS` so narration and render options are
managed entirely on the Studio page.
#### You will learn
- [how to remix a video from the web](#remix-a-video)
- [how to reapply or auto-apply a remix](#reapply-and-auto-apply)
- [how to manage narration from Studio](#studio-narration-from-code)
- [how to defer render options to Studio](#studio-render-options)
## Remix a video
Open a video in the web app and choose **Open in Studio**. Studio shows the
current narration text, voices, and render options from the latest upload.
Change what you need and choose **Save & render** — a new version is rendered
from the same recording, with your overrides applied.
Remixed versions are marked with a **Studio** badge in the version list, and
the version page shows exactly which values were changed compared to the
code-specified ones.
A remix is one-off by default: the next CI upload renders with the values
from code again.
## Reapply and auto-apply
Your Studio edits are saved with the video, so reapplying the same changes to
a newer upload is one click.
If you want every new upload to get the same treatment, enable
**Auto-apply to new uploads** in Studio. When auto-apply is active, the CLI
prints which selections were overridden in Studio as part of the upload
output, so it is always visible in CI logs that the rendered video differs
from what the code specifies.
## Studio narration from code
`createStudioNarration` declares the cue keys in code while the narration
text, languages, and voices are configured in Studio:
```ts
import { createStudioNarration, video } from 'screenci'
const narration = createStudioNarration('intro', 'checkout', 'outro')
video('Checkout walkthrough', async ({ page }) => {
await narration.intro()
await page.goto('/checkout')
await narration.checkout.start()
// ... visible workflow ...
await narration.checkout.end()
await narration.outro()
})
```
The cues behave exactly like `createNarration` cues — callable, with explicit
`start()` and `end()`, and automatic sequencing between consecutive cues.
TypeScript knows the declared keys, so `narration.typo` is a compile error.
On the **first upload** of a studio-mode video, rendering is held until
someone fills in the narration on the Studio page. The CLI prints the hold
together with a direct link to Studio:
```
Rendering for "Checkout walkthrough" is on hold — configure it in Studio:
https://app.screenci.com/project/<projectId>/video/<videoId>/studio
```
After the video has been configured once, subsequent uploads reuse the saved
Studio configuration and render automatically.
## Studio render options
Set the `renderOptions` option to `STUDIO_RENDER_OPTIONS` to manage render
options from Studio instead of code:
```ts
import { defineConfig, STUDIO_RENDER_OPTIONS } from 'screenci'
export default defineConfig({
use: {
renderOptions: STUDIO_RENDER_OPTIONS,
},
})
```
This works in the top-level `use` block and in per-project `use` blocks. Until
the video is configured in Studio, uploads render with the default render
options (or are held together with studio narration, if both are used).
## Tier requirements
Studio requires the **Business** tier. Uploads that opt into studio mode from
code are rejected at upload start on other tiers, and the Studio page shows an
upgrade prompt instead of the editor.
+1 -1
View File
@@ -198,7 +198,7 @@ API reference: [zoomTo()](/docs/reference/api/functions/zoomto),
### `createNarration()`
Use `createNarration()` to define narration cues and language variants. See
[Narration and Localization](/docs/narration-and-localization).
[Narration and Localization](/docs/guides/narration-and-localization).
Common options:
+3
View File
@@ -7,11 +7,14 @@ import * as voicesModule from './src/voices.js'
describe('public api surface', () => {
it('only exports public runtime api from the root entrypoint', () => {
expect(Object.keys(screenci).sort()).toEqual([
'STUDIO_RENDER_OPTIONS',
'autoZoom',
'createAssets',
'createNarration',
'createStudioNarration',
'defineConfig',
'hide',
'isStudioRenderOptions',
'modelTypes',
'resetZoom',
'speed',
+10 -2
View File
@@ -14,7 +14,13 @@ export type {
// Re-export voices
export { voices, modelTypes } from './src/voices.js'
export type { VoiceKey, CustomVoiceRef, ModelType } from './src/voices.js'
export type {
VoiceKey,
ModelVoiceKey,
ElevenLabsVoiceKey,
CustomVoiceRef,
ModelType,
} from './src/voices.js'
// Re-export config function
export { defineConfig } from './src/config.js'
@@ -22,8 +28,10 @@ export { defineConfig } from './src/config.js'
// Re-export video fixture and narration cue
export { video } from './src/video.js'
export type { ScreenCIPage, ScreenCILocator } from './src/types.js'
export { createNarration } from './src/cue.js'
export { createNarration, createStudioNarration } from './src/cue.js'
export type { NarrationCue, Cues, TopLevelVoiceConfig } from './src/cue.js'
export { STUDIO_RENDER_OPTIONS, isStudioRenderOptions } from './src/studio.js'
export type { StudioRenderOptionsSentinel } from './src/studio.js'
export { hide } from './src/hide.js'
export { speed } from './src/speed.js'
export { time } from './src/time.js'
+1
View File
@@ -15,6 +15,7 @@ function createMockRecorder(): IEventRecorder {
start: vi.fn(),
addInput: vi.fn(),
addCueStart: vi.fn(),
addStudioCueStart: vi.fn(),
addCueEnd: vi.fn(),
addVideoCueStart: vi.fn(),
addAssetStart: vi.fn(),
+1
View File
@@ -103,6 +103,7 @@ async function validateAssetPath(
*/
export type AssetController = () => Promise<void>
/** Typed asset controllers keyed by the names passed to {@link createAssets}. */
export type Assets<T extends Record<string, AssetConfig>> = {
[K in keyof T]: AssetController
}
+1
View File
@@ -14,6 +14,7 @@ function makeRecorder(): IEventRecorder {
start: vi.fn(),
addInput: vi.fn(),
addCueStart: vi.fn(),
addStudioCueStart: vi.fn(),
addCueEnd: vi.fn(),
addVideoCueStart: vi.fn(),
addAssetStart: vi.fn(),
+108 -2
View File
@@ -5,6 +5,7 @@ import { join } from 'path'
import { tmpdir } from 'os'
import {
createNarration,
createStudioNarration,
setActiveCueRecorder,
resetCueChain,
setSleepFn,
@@ -29,6 +30,7 @@ function createMockRecorder(): IEventRecorder {
start: vi.fn(),
addInput: vi.fn(),
addCueStart: vi.fn(),
addStudioCueStart: vi.fn(),
addCueEnd: vi.fn(),
addVideoCueStart: vi.fn(),
addAssetStart: vi.fn(),
@@ -242,7 +244,7 @@ describe('createNarration', () => {
},
})
).toThrow(
'createNarration(en) uses an ElevenLabs voice, but ELEVENLABS_API_KEY is not set. Add ELEVENLABS_API_KEY to your env file or process environment. See https://screenci.com/docs/narration-and-localization.'
'createNarration(en) uses an ElevenLabs voice, but ELEVENLABS_API_KEY is not set. Add ELEVENLABS_API_KEY to your env file or process environment. See https://screenci.com/docs/guides/narration-and-localization.'
)
})
@@ -257,7 +259,7 @@ describe('createNarration', () => {
},
})
).toThrow(
'createNarration(en) uses an ElevenLabs voice, but ELEVENLABS_API_KEY is not set. Add ELEVENLABS_API_KEY to your env file or process environment. See https://screenci.com/docs/narration-and-localization.'
'createNarration(en) uses an ElevenLabs voice, but ELEVENLABS_API_KEY is not set. Add ELEVENLABS_API_KEY to your env file or process environment. See https://screenci.com/docs/guides/narration-and-localization.'
)
})
@@ -807,3 +809,107 @@ describe('createNarration', () => {
})
})
})
describe('createStudioNarration', () => {
let recorder: IEventRecorder
let order: string[]
beforeEach(() => {
order = []
recorder = createMockRecorder()
resetCueChain()
;(
recorder.addStudioCueStart as ReturnType<typeof vi.fn>
).mockImplementation((name: string) =>
order.push(`studioCueStart(${name})`)
)
;(recorder.addCueEnd as ReturnType<typeof vi.fn>).mockImplementation(() =>
order.push('cueEnd')
)
setSleepFn(() => order.push('sleep'))
setActiveCueRecorder(recorder)
})
afterEach(() => {
setActiveCueRecorder(NOOP_EVENT_RECORDER)
setSleepFn((ms) => {
const end = performance.now() + ms
while (performance.now() < end) {}
})
})
it('exposes callable cues with start() and end() for each key', () => {
const cues = createStudioNarration('intro', 'outro')
expect(typeof cues.intro).toBe('function')
expect(typeof cues.intro.start).toBe('function')
expect(typeof cues.intro.end).toBe('function')
expect(typeof cues.outro).toBe('function')
})
it('throws on duplicate cue keys', () => {
expect(() => createStudioNarration('intro', 'intro')).toThrow(
'Duplicate cue key "intro"'
)
})
it('start() emits a studio cue start without text or translations', async () => {
const cues = createStudioNarration('intro')
await cues.intro.start()
expect(order).toEqual(['sleep', 'studioCueStart(intro)'])
expect(recorder.addCueStart).not.toHaveBeenCalled()
})
it('calling a cue runs one start and one end for a single run', async () => {
const cues = createStudioNarration('intro')
await cues.intro()
expect(order).toEqual([
'sleep',
'studioCueStart(intro)',
'sleep',
'cueEnd',
'sleep',
])
})
it('auto-ends the previous cue when the next one starts', async () => {
const cues = createStudioNarration('intro', 'outro')
await cues.intro.start()
await cues.outro.start()
expect(order).toEqual([
'sleep',
'studioCueStart(intro)',
'cueEnd',
'sleep',
'sleep',
'studioCueStart(outro)',
])
})
it('enforces unique cue names across the recording', async () => {
const studio = createStudioNarration('intro')
const regular = createNarration({
voice: { name: voices.Ava },
en: { intro: 'Hello' },
})
await studio.intro.start()
await expect(regular.intro.start()).rejects.toThrow(
'Duplicate cue name "intro"'
)
})
it('throws when started inside hide()', async () => {
setActiveHideRecorder(recorder)
const cues = createStudioNarration('intro')
await hide(async () => {
await expect(cues.intro.start()).rejects.toThrow(
'Cannot start narration inside hide()'
)
})
})
})
+61 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, assertType } from 'vitest'
import { createNarration } from './cue.js'
import { createNarration, createStudioNarration } from './cue.js'
import type { NarrationCue } from './cue.js'
import { modelTypes, voices } from './voices.js'
@@ -129,4 +129,64 @@ describe('createNarration type constraints', () => {
en: { intro: 'Hello' },
})
})
it('accepts ElevenLabs multilingual v2 voice settings', () => {
createNarration({
voice: {
name: voices.elevenlabs({ voiceId: 'voice-en' }),
stability: 0.45,
similarityBoost: 0.8,
style: 0.2,
speed: 0.9,
useSpeakerBoost: false,
},
en: { intro: 'Hello' },
})
})
it('rejects ElevenLabs settings for model voices', () => {
createNarration({
voice: {
name: voices.Ava,
// @ts-expect-error — stability is only available for ElevenLabs voices
stability: 0.5,
},
en: { intro: 'Hello' },
})
})
it('rejects model voice prompts for ElevenLabs voices', () => {
createNarration({
voice: {
name: voices.elevenlabs({ voiceId: 'voice-en' }),
// @ts-expect-error — ElevenLabs style is a numeric exaggeration setting
style: 'Friendly and energetic',
},
en: { intro: 'Hello' },
})
})
})
describe('createStudioNarration type constraints', () => {
it('types each key as a NarrationCue', () => {
const narration = createStudioNarration('intro', 'checkout', 'outro')
assertType<NarrationCue>(narration.intro)
assertType<NarrationCue>(narration.checkout)
assertType<NarrationCue>(narration.outro)
assertType<() => Promise<void>>(narration.intro.start)
assertType<() => Promise<void>>(narration.intro.end)
})
it('rejects keys that were not declared', () => {
const narration = createStudioNarration('intro')
// @ts-expect-error — "typo" was not declared as a cue key
void narration.typo
})
it('requires at least one cue key', () => {
// @ts-expect-error — at least one cue key is required
createStudioNarration()
})
})
+204 -72
View File
@@ -10,6 +10,8 @@ import {
supportedLanguages,
voices,
type VoiceKey,
type ModelVoiceKey,
type ElevenLabsVoiceKey,
type Lang,
type CustomVoiceRef,
type ModelType,
@@ -31,7 +33,7 @@ import { resolveRecordingTimingDuration } from './runtimeMode.js'
// One frame at 24fps — ensures at least one rendered frame captures each cue state.
export const ONE_FRAME_MS = 1000 / 24
const ELEVENLABS_DOCS_URL =
'https://screenci.com/docs/narration-and-localization'
'https://screenci.com/docs/guides/narration-and-localization'
// Blocking sleep — spin until the elapsed time has passed
let sleepFn = (ms: number): void => {
@@ -221,6 +223,7 @@ type NarrationCueObject =
/** A single narration cue value in a multi-language map. */
export type CueMapValue = string | NarrationCueObject
/** Typed narration controllers keyed by the cue names in a language map. */
export type Cues<T extends Record<string, CueMapValue>> = {
[K in keyof T]: NarrationCue
}
@@ -229,13 +232,33 @@ export type Cues<T extends Record<string, CueMapValue>> = {
* Top-level voice configuration shared across all languages.
* `seed` is not allowed here use per-language `voice` overrides instead.
*
* Use `style` for expressive synthesis, or `modelType` for an explicit
* model choice. `style` and `modelType` are mutually exclusive. Expressive
* synthesis and `style` prompts require the Business tier.
* Built-in model voices support expressive/consistent model controls.
* ElevenLabs voices support only the numeric settings documented below.
*/
type ElevenLabsVoiceSettings = {
/** Voice stability for ElevenLabs `eleven_multilingual_v2`. Valid range: 0 to 1. */
stability?: number
/** Similarity enhancement for ElevenLabs `eleven_multilingual_v2`. Valid range: 0 to 1. */
similarityBoost?: number
/** Style exaggeration for ElevenLabs `eleven_multilingual_v2`. Valid range: 0 to 1. */
style?: number
/** Playback speed for ElevenLabs `eleven_multilingual_v2`. Valid range: 0.7 to 1.2. */
speed?: number
/** Enables ElevenLabs speaker boost. Defaults to `true`. */
useSpeakerBoost?: boolean
}
type ElevenLabsVoiceConfig = ElevenLabsVoiceSettings & {
name: ElevenLabsVoiceKey | CustomVoiceRef
modelType?: never
accent?: never
pacing?: never
}
export type TopLevelVoiceConfig =
| ElevenLabsVoiceConfig
| {
name: VoiceKey | CustomVoiceRef
name: ModelVoiceKey
/** Speaking style prompt for expressive synthesis. Business tier only. Implies `expressive` model type. */
style: string
/** Can be omitted when `style` is set — `expressive` is implied. Business tier only. */
@@ -253,7 +276,7 @@ export type TopLevelVoiceConfig =
pacing?: string
}
| {
name: VoiceKey | CustomVoiceRef
name: ModelVoiceKey
style?: never
accent?: never
/** Speaking rate for consistent synthesis. Valid range: 0.25 to 2. */
@@ -266,13 +289,20 @@ export type TopLevelVoiceConfig =
* Per-language narration override. Can override the top-level voice name and
* optionally set a `seed` for TTS generation.
*
* Use `style` for expressive synthesis, or `modelType` for an explicit
* model choice. `style` and `modelType` are mutually exclusive. Expressive
* synthesis and `style` prompts require the Business tier.
* The voice name discriminates provider-specific settings: built-in model
* voices use expressive/consistent controls, while ElevenLabs voices use the
* numeric `eleven_multilingual_v2` controls.
*/
export type LangNarrationOverride =
| (ElevenLabsVoiceConfig & {
/**
* Integer seed included in the audio cache key and forwarded to ElevenLabs.
* A different seed always forces regeneration.
*/
seed?: number
})
| {
name: VoiceKey | CustomVoiceRef
name: ModelVoiceKey
/**
* Integer seed included in the audio cache key. A different seed always forces
* regeneration. Consistent output is not guaranteed across all voice types.
@@ -295,7 +325,7 @@ export type LangNarrationOverride =
pacing?: string
}
| {
name: VoiceKey | CustomVoiceRef
name: ModelVoiceKey
/**
* Integer seed included in the audio cache key. A different seed always forces
* regeneration. Consistent output is not guaranteed across all voice types.
@@ -426,6 +456,100 @@ export function createNarration<
) as Cues<AllCues<M>>
}
function createCueController(
name: string,
emitStart: (recorder: IEventRecorder) => void | Promise<void>
): NarrationCue {
let didRegisterName = false
const start = async (startedWithExplicitStart = true): Promise<void> => {
if (isInsideHide()) throw new Error('Cannot start narration inside hide()')
const recorder = getRuntimeCueRecorder()
const context = getScreenCIRuntimeContext()
if (!didRegisterName) {
assertUniqueCueName(name)
didRegisterName = true
}
cueAutoEnd(name)
const run = createActiveCueRun(startedWithExplicitStart)
context.cue.activeCueName = name
context.cue.activeCueRun = run
await emitStart(recorder)
}
const end = async (): Promise<void> => {
if (isInsideHide()) throw new Error('Cannot call end() inside hide()')
const context = getScreenCIRuntimeContext()
if (
context.cue.activeCueName !== name ||
context.cue.activeCueRun === null
) {
throw new Error(
`Cannot call end() for cue "${name}" because it is not the active started cue`
)
}
const run = context.cue.activeCueRun
await endActiveCue()
await run.finished
}
const cue = (async (): Promise<void> => {
await start(false)
sleepForCueFrameGap()
await end()
}) as NarrationCue
cue.start = start
cue.end = end
return cue
}
/**
* Creates typed narration controllers whose text and voice are configured on
* the ScreenCI Studio page instead of in code. Business tier only.
*
* Each key becomes a cue with the same behavior as {@link createNarration}
* cues callable, with explicit `start()` and `end()` methods. Languages,
* narration text, and voice settings all come from Studio.
*
* On the first upload of a studio-mode video, rendering is held until the
* video is configured in Studio (the CLI prints a direct link). Later uploads
* reuse the saved Studio configuration automatically.
*
* @example
* ```ts
* const narration = createStudioNarration('intro', 'checkout', 'outro')
*
* await narration.intro()
* await page.goto('/checkout')
* await narration.checkout.start()
* await narration.checkout.end()
* ```
*/
export function createStudioNarration<
const K extends readonly [string, ...string[]],
>(...keys: K): Cues<Record<K[number], CueMapValue>> {
const seen = new Set<string>()
for (const key of keys) {
if (seen.has(key)) {
throw new Error(
`Duplicate cue key "${key}" passed to createStudioNarration. Cue keys must be unique.`
)
}
seen.add(key)
}
const result = {} as Cues<Record<K[number], CueMapValue>>
for (const key of keys) {
result[key as K[number]] = createCueController(key, (recorder) => {
sleepForCueFrameGap()
recorder.addStudioCueStart(key)
})
}
return result
}
type NormalizedCueMapValue =
| { type: 'text'; text: string }
| { type: 'file'; path: string; subtitle?: string }
@@ -526,15 +650,10 @@ function buildCuesFromInput(
const langOverride = entry?.voice
const effectiveVoiceName = langOverride?.name ?? topVoice.name
const effectiveSeed = langOverride?.seed
// If a lang override exists it owns style/accent/pacing entirely — no inheritance from the
// top-level voice. This prevents a top-level `style` from forcing `expressive` on a lang
// that explicitly sets `modelType: 'consistent'`.
// A language override owns all provider settings. This prevents settings
// for one provider from leaking across a voice override to another.
const effectiveStyle =
langOverride !== undefined
? langOverride?.style
: 'style' in topVoice
? (topVoice as { style: string }).style
: undefined
langOverride !== undefined ? langOverride.style : topVoice.style
const effectiveAccent =
langOverride !== undefined
? langOverride?.accent
@@ -547,9 +666,42 @@ function buildCuesFromInput(
: 'pacing' in topVoice
? (topVoice as { pacing?: string | number }).pacing
: undefined
const effectiveModelType = effectiveStyle
? 'expressive'
: (langOverride?.modelType ?? topVoice.modelType)
const effectiveStability =
langOverride !== undefined
? 'stability' in langOverride
? langOverride.stability
: undefined
: 'stability' in topVoice
? topVoice.stability
: undefined
const effectiveSimilarityBoost =
langOverride !== undefined
? 'similarityBoost' in langOverride
? langOverride.similarityBoost
: undefined
: 'similarityBoost' in topVoice
? topVoice.similarityBoost
: undefined
const effectiveSpeed =
langOverride !== undefined
? 'speed' in langOverride
? langOverride.speed
: undefined
: 'speed' in topVoice
? topVoice.speed
: undefined
const effectiveUseSpeakerBoost =
langOverride !== undefined
? 'useSpeakerBoost' in langOverride
? langOverride.useSpeakerBoost
: undefined
: 'useSpeakerBoost' in topVoice
? topVoice.useSpeakerBoost
: undefined
const effectiveModelType =
typeof effectiveStyle === 'string'
? 'expressive'
: (langOverride?.modelType ?? topVoice.modelType)
assertElevenLabsApiKeyConfigured(
effectiveVoiceName,
@@ -566,6 +718,16 @@ function buildCuesFromInput(
...(effectiveStyle !== undefined && { style: effectiveStyle }),
...(effectiveAccent !== undefined && { accent: effectiveAccent }),
...(effectivePacing !== undefined && { pacing: effectivePacing }),
...(effectiveStability !== undefined && {
stability: effectiveStability,
}),
...(effectiveSimilarityBoost !== undefined && {
similarityBoost: effectiveSimilarityBoost,
}),
...(effectiveSpeed !== undefined && { speed: effectiveSpeed }),
...(effectiveUseSpeakerBoost !== undefined && {
useSpeakerBoost: effectiveUseSpeakerBoost,
}),
})
}
@@ -577,56 +739,6 @@ function buildCuesFromInput(
const firstCues = getLanguageCues(firstLang, firstEntry)
if (firstCues === undefined) return {} as Cues<Record<string, CueMapValue>>
function createCueController(
name: string,
emitStart: (recorder: IEventRecorder) => void | Promise<void>
): NarrationCue {
let didRegisterName = false
const start = async (startedWithExplicitStart = true): Promise<void> => {
if (isInsideHide())
throw new Error('Cannot start narration inside hide()')
const recorder = getRuntimeCueRecorder()
const context = getScreenCIRuntimeContext()
if (!didRegisterName) {
assertUniqueCueName(name)
didRegisterName = true
}
cueAutoEnd(name)
const run = createActiveCueRun(startedWithExplicitStart)
context.cue.activeCueName = name
context.cue.activeCueRun = run
await emitStart(recorder)
}
const end = async (): Promise<void> => {
if (isInsideHide()) throw new Error('Cannot call end() inside hide()')
const context = getScreenCIRuntimeContext()
if (
context.cue.activeCueName !== name ||
context.cue.activeCueRun === null
) {
throw new Error(
`Cannot call end() for cue "${name}" because it is not the active started cue`
)
}
const run = context.cue.activeCueRun
await endActiveCue()
await run.finished
}
const cue = (async (): Promise<void> => {
await start(false)
sleepForCueFrameGap()
await end()
}) as NarrationCue
cue.start = start
cue.end = end
return cue
}
for (const key in firstCues) {
const keyStr = key
@@ -661,6 +773,11 @@ function buildCuesFromInput(
const style = meta?.style
const accent = meta?.accent
const pacing = meta?.pacing
const stability = meta?.stability
const similarityBoost = meta?.similarityBoost
const speed = meta?.speed
const useSpeakerBoost = meta?.useSpeakerBoost
const seed = meta?.seed
if (val.type === 'text') {
videoTranslations[lang] = {
text: val.text,
@@ -669,6 +786,11 @@ function buildCuesFromInput(
...(style !== undefined && { style }),
...(accent !== undefined && { accent }),
...(pacing !== undefined && { pacing }),
...(stability !== undefined && { stability }),
...(similarityBoost !== undefined && { similarityBoost }),
...(speed !== undefined && { speed }),
...(useSpeakerBoost !== undefined && { useSpeakerBoost }),
...(seed !== undefined && { seed }),
}
} else {
videoTranslations[lang] = await entryToVideoTranslation(
@@ -704,6 +826,11 @@ function buildCuesFromInput(
const style = meta?.style
const accent = meta?.accent
const pacing = meta?.pacing
const stability = meta?.stability
const similarityBoost = meta?.similarityBoost
const speed = meta?.speed
const useSpeakerBoost = meta?.useSpeakerBoost
const seed = meta?.seed
textTranslations[lang] = {
text: val.text,
voice: await toRecordedVoice(voice),
@@ -711,6 +838,11 @@ function buildCuesFromInput(
...(style !== undefined && { style }),
...(accent !== undefined && { accent }),
...(pacing !== undefined && { pacing }),
...(stability !== undefined && { stability }),
...(similarityBoost !== undefined && { similarityBoost }),
...(speed !== undefined && { speed }),
...(useSpeakerBoost !== undefined && { useSpeakerBoost }),
...(seed !== undefined && { seed }),
}
}
}
+71
View File
@@ -5,6 +5,7 @@ import { tmpdir } from 'os'
import { readFile } from 'fs/promises'
import { EventRecorder } from './events.js'
import type { RecordingData, InputEvent } from './events.js'
import { STUDIO_RENDER_OPTIONS, isStudioRenderOptions } from './studio.js'
import { voices } from './voices.js'
describe('EventRecorder', () => {
@@ -597,5 +598,75 @@ describe('EventRecorder', () => {
const parsed = JSON.parse(content) as { metadata?: { voices?: unknown } }
expect(parsed.metadata?.voices).toBeUndefined()
})
describe('studio mode', () => {
it('writes resolved defaults and metadata.studio.renderOptions for STUDIO_RENDER_OPTIONS', async () => {
recorder = new EventRecorder(STUDIO_RENDER_OPTIONS)
recorder.start()
await recorder.writeToFile(tmpDir, 'Test Video')
const content = await readFile(join(tmpDir, 'data.json'), 'utf-8')
const parsed: RecordingData = JSON.parse(content)
// data.json always contains a complete, renderable set of options
expect(parsed.renderOptions.recording.size).toBe(1.0)
expect(parsed.renderOptions.output.aspectRatio).toBe('16:9')
expect(parsed.metadata?.studio).toEqual({ renderOptions: true })
})
it('records studio cue starts and sets metadata.studio.narration', async () => {
recorder.start()
now = 1500
recorder.addStudioCueStart('intro')
await recorder.writeToFile(tmpDir, 'Test Video')
const content = await readFile(join(tmpDir, 'data.json'), 'utf-8')
const parsed: RecordingData = JSON.parse(content)
expect(parsed.events[1]).toEqual({
type: 'cueStart',
timeMs: 500,
name: 'intro',
studio: true,
})
expect(parsed.metadata?.studio).toEqual({ narration: true })
// studio cues have no translations, so no language list is derived
expect(parsed.metadata?.languages).toBeUndefined()
})
it('sets both studio flags when sentinel and studio cues are combined', async () => {
recorder = new EventRecorder(STUDIO_RENDER_OPTIONS)
recorder.start()
recorder.addStudioCueStart('intro')
await recorder.writeToFile(tmpDir, 'Test Video')
const content = await readFile(join(tmpDir, 'data.json'), 'utf-8')
const parsed: RecordingData = JSON.parse(content)
expect(parsed.metadata?.studio).toEqual({
renderOptions: true,
narration: true,
})
})
it('writes no metadata.studio for regular recordings', async () => {
recorder = new EventRecorder({ recording: { size: 0.8 } })
recorder.start()
recorder.addCueStart('', 'greeting', undefined, {
en: { text: 'Hello', voice: voices.Ava },
})
await recorder.writeToFile(tmpDir, 'Test Video')
const content = await readFile(join(tmpDir, 'data.json'), 'utf-8')
const parsed: RecordingData = JSON.parse(content)
expect(parsed.metadata?.studio).toBeUndefined()
})
it('survives JSON serialization of the sentinel (Playwright use options)', () => {
const roundTripped: unknown = JSON.parse(
JSON.stringify(STUDIO_RENDER_OPTIONS)
)
expect(isStudioRenderOptions(roundTripped)).toBe(true)
expect(isStudioRenderOptions({ recording: { size: 1 } })).toBe(false)
expect(isStudioRenderOptions(undefined)).toBe(false)
})
})
})
})
+77 -9
View File
@@ -12,6 +12,10 @@ import type {
ResolvedRenderOptions,
} from './types.js'
import { RENDER_OPTIONS_DEFAULTS } from './types.js'
import {
isStudioRenderOptions,
type StudioRenderOptionsSentinel,
} from './studio.js'
import type { VoiceKey } from './voices.js'
import { DEFAULT_ZOOM_OPTIONS } from './defaults.js'
import { getGitMetadata } from './git.js'
@@ -167,12 +171,20 @@ export type CueTranslation = {
voice: VoiceKey | RecordingCustomVoiceRef
/** TTS model type — `'expressive'` or `'consistent'`. Defaults to `'consistent'`. `'expressive'` requires the Business tier. */
modelType?: string
/** Speaking style prompt for expressive synthesis. Business tier only. */
style?: string
/** Gemini style prompt, or ElevenLabs `eleven_multilingual_v2` style exaggeration. */
style?: string | number
/** Accent description for expressive synthesis. Omitted from the prompt when not set. */
accent?: string
/** Pacing description for expressive synthesis, or speaking rate for consistent synthesis. */
pacing?: string | number
/** ElevenLabs `eleven_multilingual_v2` stability, from 0 to 1. */
stability?: number
/** ElevenLabs `eleven_multilingual_v2` similarity boost, from 0 to 1. */
similarityBoost?: number
/** ElevenLabs `eleven_multilingual_v2` speed, from 0.7 to 1.2. */
speed?: number
/** Whether ElevenLabs speaker boost is enabled. */
useSpeakerBoost?: boolean
/**
* Integer seed included in the audio cache key. A different seed always forces
* regeneration. Consistent output is not guaranteed across all voice types.
@@ -184,6 +196,8 @@ export type CueStartEvent = {
type: 'cueStart'
timeMs: number
name: string
/** Cue declared via `createStudioNarration` — text and voice come from Studio. */
studio?: true
/** Single-language API (backward compat) */
text?: string
cueConfig?: CueConfig
@@ -211,12 +225,16 @@ export type VideoCueTranslationTTS = {
voice: VoiceKey | RecordingCustomVoiceRef
/** TTS model type — `'expressive'` or `'consistent'`. Defaults to `'consistent'`. `'expressive'` requires the Business tier. */
modelType?: string
/** Speaking style prompt for expressive synthesis. Business tier only. */
style?: string
/** Gemini style prompt, or ElevenLabs `eleven_multilingual_v2` style exaggeration. */
style?: string | number
/** Accent description for expressive synthesis. Omitted from the prompt when not set. */
accent?: string
/** Pacing description for expressive synthesis, or speaking rate for consistent synthesis. */
pacing?: string | number
stability?: number
similarityBoost?: number
speed?: number
useSpeakerBoost?: boolean
/**
* Integer seed included in the audio cache key. A different seed always forces
* regeneration. Consistent output is not guaranteed across all voice types.
@@ -346,12 +364,16 @@ export type VoiceLanguageMeta = {
seed?: number
/** TTS model type — `'expressive'` or `'consistent'`. Defaults to `'consistent'`. `'expressive'` requires the Business tier. */
modelType?: string
/** Speaking style prompt for expressive synthesis. Business tier only. */
style?: string
/** Gemini style prompt, or ElevenLabs `eleven_multilingual_v2` style exaggeration. */
style?: string | number
/** Accent description for expressive synthesis. Omitted from the prompt when not set. */
accent?: string
/** Pacing description for expressive synthesis, or speaking rate for consistent synthesis. */
pacing?: string | number
stability?: number
similarityBoost?: number
speed?: number
useSpeakerBoost?: boolean
}
export type RecordingMetadata = {
@@ -360,6 +382,15 @@ export type RecordingMetadata = {
/** Language codes present in multi-language cues, e.g. `['en', 'de']`. Omitted when no multi-language cues are used. */
languages?: string[]
sourceFilePath?: string
/**
* Which parts of this recording opted into Studio configuration.
* `renderOptions` is set when `STUDIO_RENDER_OPTIONS` was used; `narration`
* when the recording contains `createStudioNarration` cues.
*/
studio?: {
renderOptions?: boolean
narration?: boolean
}
}
function readScreenciVersion(): string {
@@ -417,6 +448,8 @@ export interface IEventRecorder {
cueConfig?: CueConfig,
translations?: Record<string, CueTranslation>
): void
/** Records a studio-mode cue start — text and voice are configured in Studio. */
addStudioCueStart(name: string): void
addCueEnd(reason?: 'auto' | 'wait'): void
addVideoCueStart(
name: string,
@@ -451,6 +484,7 @@ export const NOOP_EVENT_RECORDER: IEventRecorder = {
start(): void {},
addInput(): void {},
addCueStart(): void {},
addStudioCueStart(): void {},
addCueEnd(): void {},
addVideoCueStart(): void {},
addAssetStart(): void {},
@@ -473,9 +507,15 @@ export class EventRecorder implements IEventRecorder {
private readonly events: RecordingEvent[] = []
private startTime: number | null = null
private readonly recordOptions: RecordOptions | undefined
private readonly renderOptions: RenderOptions | undefined
private readonly renderOptions:
| RenderOptions
| StudioRenderOptionsSentinel
| undefined
constructor(renderOptions?: RenderOptions, recordOptions?: RecordOptions) {
constructor(
renderOptions?: RenderOptions | StudioRenderOptionsSentinel,
recordOptions?: RecordOptions
) {
this.recordOptions = recordOptions
this.renderOptions = renderOptions
}
@@ -636,6 +676,17 @@ export class EventRecorder implements IEventRecorder {
})
}
addStudioCueStart(name: string): void {
if (this.startTime === null) return
const timeMs = Date.now() - this.startTime
this.events.push({
type: 'cueStart',
timeMs,
name,
studio: true,
})
}
addCueEnd(reason?: 'auto' | 'wait'): void {
if (this.startTime === null) return
const timeMs = Date.now() - this.startTime
@@ -800,9 +851,14 @@ export class EventRecorder implements IEventRecorder {
): Promise<void> {
const filePath = join(dir, 'data.json')
// Studio mode: render options come from the Studio page. data.json still
// gets fully-resolved defaults (so it always validates and renders), and
// metadata.studio.renderOptions marks the deferral for the backend.
const studioRenderOptions = isStudioRenderOptions(this.renderOptions)
// Resolve all defaults so data.json always contains a complete set of
// render options.
const ro = this.renderOptions
const ro = studioRenderOptions ? undefined : this.renderOptions
const resolved: ResolvedRenderOptions = {
recording: {
size: ro?.recording?.size ?? RENDER_OPTIONS_DEFAULTS.recording.size,
@@ -862,6 +918,17 @@ export class EventRecorder implements IEventRecorder {
const git = getGitMetadata()
const studioNarration = this.events.some(
(event) => event.type === 'cueStart' && event.studio === true
)
const studio: RecordingMetadata['studio'] =
studioRenderOptions || studioNarration
? {
...(studioRenderOptions && { renderOptions: true }),
...(studioNarration && { narration: true }),
}
: undefined
const data: RecordingData = {
events: this.events,
renderOptions: resolved,
@@ -875,6 +942,7 @@ export class EventRecorder implements IEventRecorder {
...(sourceFilePath !== undefined && { sourceFilePath }),
...(git.commit !== undefined && { commit: git.commit }),
...(git.isDirty !== undefined && { isDirty: git.isDirty }),
...(studio !== undefined && { studio }),
},
}
await writeFile(filePath, JSON.stringify(data, null, 2))
+1
View File
@@ -7,6 +7,7 @@ function makeRecorder(): IEventRecorder {
start: vi.fn(),
addInput: vi.fn(),
addCueStart: vi.fn(),
addStudioCueStart: vi.fn(),
addCueEnd: vi.fn(),
addVideoCueStart: vi.fn(),
addAssetStart: vi.fn(),
+1
View File
@@ -53,6 +53,7 @@ function makeRecorder() {
}
),
addCueStart: vi.fn(),
addStudioCueStart: vi.fn(),
addCueEnd: vi.fn(),
addHideStart: vi.fn(),
addHideEnd: vi.fn(),
+18
View File
@@ -131,6 +131,15 @@ export type RecordingCustomVoiceRef = {
export type CueTranslation = {
text: string
voice: VoiceKey | RecordingCustomVoiceRef
modelType?: string
style?: string | number
accent?: string
pacing?: string | number
stability?: number
similarityBoost?: number
speed?: number
useSpeakerBoost?: boolean
seed?: number
}
export type CueStartEvent = {
@@ -157,6 +166,15 @@ export type VideoCueTranslationFile = {
export type VideoCueTranslationTTS = {
text: string
voice: VoiceKey | RecordingCustomVoiceRef
modelType?: string
style?: string | number
accent?: string
pacing?: string | number
stability?: number
similarityBoost?: number
speed?: number
useSpeakerBoost?: boolean
seed?: number
}
export type VideoCueTranslation =
+46
View File
@@ -0,0 +1,46 @@
/**
* Studio mode configure narration and render options from the ScreenCI web
* app instead of code. Business tier only.
*
* Code opts in per concern:
* - `createStudioNarration('intro', 'outro')` declares cue keys whose text and
* voice are filled in on the Studio page.
* - `renderOptions: STUDIO_RENDER_OPTIONS` defers render options to Studio.
*
* On the first upload of a studio-mode video, rendering is held until the
* video is configured in Studio; later uploads reuse the saved configuration.
*/
/**
* Sentinel value for the `renderOptions` fixture/config option meaning
* "render options are configured in Studio".
*
* Implemented as a frozen, JSON-safe branded object (not a string or symbol)
* so it survives Playwright's serialization of `use` options between the
* config file and test workers, and cannot collide with a real
* {@link import('./types.js').RenderOptions} value.
*
* @example
* ```ts
* import { defineConfig, STUDIO_RENDER_OPTIONS } from 'screenci'
*
* export default defineConfig({
* use: { renderOptions: STUDIO_RENDER_OPTIONS },
* })
* ```
*/
export const STUDIO_RENDER_OPTIONS = Object.freeze({
__screenciStudioRenderOptions: true,
} as const)
export type StudioRenderOptionsSentinel = typeof STUDIO_RENDER_OPTIONS
export function isStudioRenderOptions(
value: unknown
): value is StudioRenderOptionsSentinel {
return (
typeof value === 'object' &&
value !== null &&
(value as Record<string, unknown>).__screenciStudioRenderOptions === true
)
}
+1
View File
@@ -9,6 +9,7 @@ function makeRecorder(): IEventRecorder {
start: vi.fn(),
addInput: vi.fn(),
addCueStart: vi.fn(),
addStudioCueStart: vi.fn(),
addCueEnd: vi.fn(),
addVideoCueStart: vi.fn(),
addAssetStart: vi.fn(),
+23 -9
View File
@@ -5,6 +5,7 @@ import type {
Locator,
Mouse,
} from '@playwright/test'
import type { StudioRenderOptionsSentinel } from './studio.js'
/**
* Aspect ratio for recording and output.
@@ -710,14 +711,25 @@ export type ScreenCIPage = Omit<
getByTitle(...args: Parameters<Page['getByTitle']>): ScreenCILocator
}
import type { VoiceKey } from './voices.js'
import type { ElevenLabsVoiceKey, ModelVoiceKey } from './voices.js'
export type CueConfig = {
voice: VoiceKey
speed?: number
stability?: number
style?: number
}
export type CueConfig =
| {
voice: ElevenLabsVoiceKey
speed?: number
stability?: number
similarityBoost?: number
style?: number
useSpeakerBoost?: boolean
}
| {
voice: ModelVoiceKey
speed?: never
stability?: never
similarityBoost?: never
style?: never
useSpeakerBoost?: never
}
export type ScreenCIConfig = Omit<
PlaywrightTestConfig,
@@ -774,7 +786,8 @@ export type ScreenCIConfig = Omit<
webServer?: PlaywrightTestConfig['webServer']
use?: Omit<NonNullable<PlaywrightTestConfig['use']>, 'trace'> & {
recordOptions?: RecordOptions
renderOptions?: RenderOptions
/** Render options, or `STUDIO_RENDER_OPTIONS` to configure them in Studio (Business tier). */
renderOptions?: RenderOptions | StudioRenderOptionsSentinel
/**
* Timeout in milliseconds for individual actions like `click()`, `fill()`, etc.
*
@@ -803,7 +816,8 @@ export type ScreenCIConfig = Omit<
projects?: (Omit<Project, 'use'> & {
use?: Omit<NonNullable<Project['use']>, 'trace'> & {
recordOptions?: RecordOptions
renderOptions?: RenderOptions
/** Render options, or `STUDIO_RENDER_OPTIONS` to configure them in Studio (Business tier). */
renderOptions?: RenderOptions | StudioRenderOptionsSentinel
/**
* When to record traces during test execution.
* Uses Playwright's native `trace` option type.
+2 -1
View File
@@ -22,6 +22,7 @@ import type {
ScreenCIPage,
} from './types.js'
import type { Page } from '@playwright/test'
import type { StudioRenderOptionsSentinel } from './studio.js'
export { getDimensions } from './dimensions.js'
import { getDimensions, getViewportCenter } from './dimensions.js'
import { resetCueChain } from './cue.js'
@@ -218,7 +219,7 @@ async function withActiveRecordingContext<T>(params: {
type VideoFixtureOptions = {
recordOptions: RecordOptions
renderOptions: RenderOptions | undefined
renderOptions: RenderOptions | StudioRenderOptionsSentinel | undefined
}
const _videoBase = base.extend<
+3 -3
View File
@@ -140,11 +140,11 @@ export const voices = {
`elevenlabs:${voiceId}` as ElevenLabsVoiceKey,
} as const
type VoiceName = keyof Omit<typeof voices, 'elevenlabs'>
type ElevenLabsVoiceKey = `elevenlabs:${string}`
export type ModelVoiceKey = keyof Omit<typeof voices, 'elevenlabs'>
export type ElevenLabsVoiceKey = `elevenlabs:${string}`
/** Union of all valid voice keys, e.g. `'Aria' | 'elevenlabs:abc123'`. */
export type VoiceKey = VoiceName | ElevenLabsVoiceKey
export type VoiceKey = ModelVoiceKey | ElevenLabsVoiceKey
// DOCS_SYNC:
// Keep the "Available languages" section in docs/narration-and-localization.md