mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
feat(screenci): add studio support
This commit is contained in:
+113
-1
@@ -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: [],
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -53,6 +53,7 @@ function makeRecorder() {
|
||||
}
|
||||
),
|
||||
addCueStart: vi.fn(),
|
||||
addStudioCueStart: vi.fn(),
|
||||
addCueEnd: vi.fn(),
|
||||
addHideStart: vi.fn(),
|
||||
addHideEnd: vi.fn(),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user