mirror of
https://github.com/screenci/screenci.git
synced 2026-09-19 08:57:46 +08:00
feat(screenci): add cue localization and git helpers
This commit is contained in:
@@ -125,6 +125,11 @@ export default defineConfig({
|
||||
ScreenCI manages `testDir`, `testMatch`, and `retries` for you. Most other
|
||||
Playwright config still passes through.
|
||||
|
||||
If you keep local runtime secrets in an env file, point `envFile` at it or use
|
||||
the project `.env`. ScreenCI loads that file automatically for CLI commands.
|
||||
That is also the right place for BYOK-style secrets such as
|
||||
`ELEVENLABS_API_KEY`. ScreenCI does not store raw API keys from your env file.
|
||||
|
||||
## Authoring helpers
|
||||
|
||||
| Export | What it does |
|
||||
@@ -151,3 +156,7 @@ Playwright config still passes through.
|
||||
|
||||
When `SCREENCI_SECRET` is configured, `screenci record` uploads the output to
|
||||
ScreenCI for rendering, narration generation, and hosted delivery.
|
||||
|
||||
For narration authoring, keep cues short and usually one sentence at a time.
|
||||
That makes overlap timing easier to manage and should reduce TTS regeneration
|
||||
cost when only one line changes.
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { main } from '../dist/cli.js'
|
||||
import { logCliError, main } from '../dist/cli.js'
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Error:', error.message)
|
||||
logCliError(error)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
+36
-4
@@ -541,10 +541,20 @@ describe('CLI', () => {
|
||||
return mockChildProcess as unknown as ChildProcess
|
||||
})
|
||||
|
||||
const { main } = await import('./cli')
|
||||
const { main, logCliError } = await import('./cli')
|
||||
|
||||
await expect(main()).rejects.toThrow('Playwright exited with code 1')
|
||||
const error = await main().catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe('Playwright exited with code 1')
|
||||
|
||||
loggerErrorSpy.mockClear()
|
||||
loggerInfoSpy.mockClear()
|
||||
logCliError(error)
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
||||
'Playwright exited with code 1'
|
||||
)
|
||||
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('screenci test --mock-record')
|
||||
)
|
||||
@@ -553,6 +563,9 @@ describe('CLI', () => {
|
||||
'https://screenci.com/docs/reference/cli/#--mock-record'
|
||||
)
|
||||
)
|
||||
expect(loggerErrorSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loggerInfoSpy.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces the first Playwright discovery error and snippet instead of raw JSON', async () => {
|
||||
@@ -585,11 +598,30 @@ describe('CLI', () => {
|
||||
return mockChildProcess as unknown as ChildProcess
|
||||
})
|
||||
|
||||
const { main } = await import('./cli')
|
||||
const { main, logCliError } = await import('./cli')
|
||||
|
||||
await expect(main()).rejects.toThrow(
|
||||
const error = await main().catch((err) => err)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toContain(
|
||||
'Error: [screenci] Asset "badge" (./assets/brand-badge.svg) is an image asset and must not provide audio. Use durationMs instead.\n\nat styled-assets.video.ts:24'
|
||||
)
|
||||
|
||||
loggerErrorSpy.mockClear()
|
||||
loggerInfoSpy.mockClear()
|
||||
logCliError(error)
|
||||
|
||||
expect(loggerErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Error: [screenci] Asset "badge" (./assets/brand-badge.svg) is an image asset and must not provide audio. Use durationMs instead.\n\nat styled-assets.video.ts:24'
|
||||
)
|
||||
)
|
||||
expect(loggerInfoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('screenci test --mock-record')
|
||||
)
|
||||
expect(loggerErrorSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
loggerInfoSpy.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -600,6 +600,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
@@ -611,6 +612,88 @@ describe('CLI', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards ELEVENLABS_API_KEY during upload requests when configured', async () => {
|
||||
process.env.ELEVENLABS_API_KEY = 'elevenlabs-byok-key'
|
||||
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('record-upload.config.ts')) {
|
||||
return "export default { projectName: 'Test Project' }"
|
||||
}
|
||||
if (pathString.endsWith('data.json')) {
|
||||
return JSON.stringify({ events: [], metadata: { videoName: 'Demo' } })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
mockExistsSync.mockImplementation(
|
||||
(path: string) =>
|
||||
path.endsWith('test-fixtures/record-upload.config.ts') ||
|
||||
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',
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
}
|
||||
|
||||
if (url.endsWith('/cli/upload/recording_123/recording')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockResolvedValue({}),
|
||||
text: vi.fn().mockResolvedValue(''),
|
||||
}
|
||||
})
|
||||
|
||||
const { uploadRecordings } = await import('./cli')
|
||||
|
||||
await uploadRecordings(
|
||||
'/repo/.screenci',
|
||||
'Test Project',
|
||||
'https://api.screenci.test',
|
||||
'test-secret'
|
||||
)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.screenci.test/cli/upload/start',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-ScreenCI-Secret': 'test-secret',
|
||||
'X-ElevenLabs-Api-Key': 'elevenlabs-byok-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.screenci.test/cli/upload/recording_123/recording',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-ScreenCI-Secret': 'test-secret',
|
||||
'X-ElevenLabs-Api-Key': 'elevenlabs-byok-key',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads completed recordings after partial failure with default policy, then still fails', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
@@ -757,6 +840,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
@@ -791,6 +875,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: null,
|
||||
recordId: expect.any(String),
|
||||
hadFailures: true,
|
||||
failedVideoNames: ['Demo'],
|
||||
failedVideoMessages: [
|
||||
@@ -888,6 +973,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: true,
|
||||
failedVideoNames: ['Demo'],
|
||||
failedVideoMessages: [
|
||||
@@ -1004,6 +1090,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
@@ -1094,6 +1181,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: true,
|
||||
failedVideoNames: ['Failed Demo'],
|
||||
failedVideoMessages: [
|
||||
@@ -1163,6 +1251,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
@@ -1232,6 +1321,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
@@ -1332,6 +1422,7 @@ describe('CLI', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
projectId: 'project_123',
|
||||
recordId: expect.any(String),
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
realpathSync,
|
||||
rmSync,
|
||||
} from 'fs'
|
||||
import { createHash } from 'crypto'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { createRequire } from 'module'
|
||||
import { appendFile, readdir, readFile, stat, writeFile } from 'fs/promises'
|
||||
import { delimiter, dirname, relative as pathRelative, resolve } from 'path'
|
||||
@@ -369,6 +369,7 @@ type UploadJobResult = {
|
||||
hadFailure: boolean
|
||||
videoName: string
|
||||
failureMessage?: string
|
||||
recordId: string
|
||||
}
|
||||
|
||||
type UploadProgressStatus = 'success' | 'failure' | 'cancelled'
|
||||
@@ -394,6 +395,16 @@ class PartialUploadError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class RecordFailureHintError extends Error {
|
||||
readonly cause: Error
|
||||
|
||||
constructor(cause: Error) {
|
||||
super(cause.message)
|
||||
this.name = cause.name
|
||||
this.cause = cause
|
||||
}
|
||||
}
|
||||
|
||||
function isUploadCancelledError(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof UploadCancelledError ||
|
||||
@@ -406,6 +417,10 @@ function isPartialUploadError(err: unknown): boolean {
|
||||
return err instanceof PartialUploadError
|
||||
}
|
||||
|
||||
function isRecordFailureHintError(err: unknown): err is RecordFailureHintError {
|
||||
return err instanceof RecordFailureHintError
|
||||
}
|
||||
|
||||
function isUploadAssetError(err: unknown): boolean {
|
||||
return err instanceof UploadAssetError
|
||||
}
|
||||
@@ -550,13 +565,15 @@ async function uploadRecordingCandidate(
|
||||
projectName: string,
|
||||
apiUrl: string,
|
||||
secret: string,
|
||||
elevenLabsApiKey: string | undefined,
|
||||
verbose: boolean,
|
||||
uploadAbort: ReturnType<typeof createUploadAbortController>,
|
||||
progressReporter: {
|
||||
complete: (index: number, status: UploadProgressStatus) => void
|
||||
info: (message: string) => void
|
||||
},
|
||||
progressIndex: number
|
||||
progressIndex: number,
|
||||
recordId: string
|
||||
): Promise<UploadJobResult> {
|
||||
const { entry, videoName, data, preparedUploadAssets } = candidate
|
||||
let projectId: string | null = null
|
||||
@@ -571,6 +588,7 @@ async function uploadRecordingCandidate(
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
failureMessage: `Missing recording.mp4 for "${videoName}"`,
|
||||
recordId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,12 +598,16 @@ async function uploadRecordingCandidate(
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-ScreenCI-Secret': secret,
|
||||
...(elevenLabsApiKey
|
||||
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectName,
|
||||
videoName,
|
||||
data,
|
||||
recordingHash,
|
||||
recordId,
|
||||
expectedAssets: preparedUploadAssets.map((asset) => ({
|
||||
fileHash: asset.fileHash,
|
||||
size: asset.size,
|
||||
@@ -612,6 +634,7 @@ async function uploadRecordingCandidate(
|
||||
text,
|
||||
secret
|
||||
),
|
||||
recordId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,6 +687,9 @@ async function uploadRecordingCandidate(
|
||||
'Content-Type': 'video/mp4',
|
||||
'Content-Length': String(fileStat.size),
|
||||
'X-ScreenCI-Secret': secret,
|
||||
...(elevenLabsApiKey
|
||||
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
||||
: {}),
|
||||
},
|
||||
body: stream as unknown as BodyInit,
|
||||
signal: uploadAbort.signal,
|
||||
@@ -679,6 +705,7 @@ async function uploadRecordingCandidate(
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
||||
recordId,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -687,7 +714,7 @@ async function uploadRecordingCandidate(
|
||||
|
||||
progressReporter.complete(progressIndex, 'success')
|
||||
cleanupUploadedRecordingDir(screenciDir, entry)
|
||||
return { projectId, hadFailure: false, videoName }
|
||||
return { projectId, hadFailure: false, videoName, recordId }
|
||||
} catch (err) {
|
||||
if (isUploadCancelledError(err)) {
|
||||
progressReporter.complete(progressIndex, 'cancelled')
|
||||
@@ -701,6 +728,7 @@ async function uploadRecordingCandidate(
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
failureMessage: err instanceof Error ? err.message : String(err),
|
||||
recordId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,6 +738,7 @@ async function uploadRecordingCandidate(
|
||||
hadFailure: true,
|
||||
videoName,
|
||||
failureMessage: `Network error uploading "${videoName}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
recordId,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1435,11 +1464,13 @@ export async function uploadRecordings(
|
||||
verbose = false
|
||||
): Promise<{
|
||||
projectId: string | null
|
||||
recordId: string | null
|
||||
hadFailures: boolean
|
||||
failedVideoNames: string[]
|
||||
failedVideoMessages: Array<{ videoName: string; message: string }>
|
||||
}> {
|
||||
const uploadAbort = createUploadAbortController('upload')
|
||||
const recordId = randomUUID()
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(screenciDir)
|
||||
@@ -1447,6 +1478,7 @@ export async function uploadRecordings(
|
||||
logger.warn('No .screenci directory found, skipping upload')
|
||||
return {
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
@@ -1458,6 +1490,7 @@ export async function uploadRecordings(
|
||||
}
|
||||
|
||||
let firstProjectId: string | null = null
|
||||
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined
|
||||
|
||||
try {
|
||||
const candidates = (
|
||||
@@ -1472,6 +1505,7 @@ export async function uploadRecordings(
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
@@ -1492,10 +1526,12 @@ export async function uploadRecordings(
|
||||
projectName,
|
||||
apiUrl,
|
||||
secret,
|
||||
elevenLabsApiKey,
|
||||
verbose,
|
||||
uploadAbort,
|
||||
progressReporter,
|
||||
index
|
||||
index,
|
||||
recordId
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1514,6 +1550,7 @@ export async function uploadRecordings(
|
||||
|
||||
return {
|
||||
projectId: firstProjectId,
|
||||
recordId,
|
||||
hadFailures,
|
||||
failedVideoNames,
|
||||
failedVideoMessages,
|
||||
@@ -2199,14 +2236,11 @@ export async function main() {
|
||||
try {
|
||||
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose)
|
||||
} catch (error) {
|
||||
logRecordFailureHint()
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.startsWith('Playwright exited with code ')
|
||||
) {
|
||||
playwrightFailure = error
|
||||
if (!(error instanceof Error)) throw error
|
||||
if (error.message.startsWith('Playwright exited with code ')) {
|
||||
playwrightFailure = new RecordFailureHintError(error)
|
||||
} else {
|
||||
throw error
|
||||
throw new RecordFailureHintError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2253,11 +2287,13 @@ export async function main() {
|
||||
}
|
||||
let uploadResult: {
|
||||
projectId: string | null
|
||||
recordId: string | null
|
||||
hadFailures: boolean
|
||||
failedVideoNames: string[]
|
||||
failedVideoMessages: Array<{ videoName: string; message: string }>
|
||||
} = {
|
||||
projectId: null,
|
||||
recordId: null,
|
||||
hadFailures: false,
|
||||
failedVideoNames: [],
|
||||
failedVideoMessages: [],
|
||||
@@ -2277,11 +2313,22 @@ export async function main() {
|
||||
}
|
||||
const {
|
||||
projectId,
|
||||
recordId,
|
||||
hadFailures,
|
||||
failedVideoNames,
|
||||
failedVideoMessages,
|
||||
} = uploadResult
|
||||
if (projectId !== null) {
|
||||
if (recordId !== null && projectId !== null) {
|
||||
const recordUrl = `${appUrl}/record/${recordId}`
|
||||
await writeGitHubProjectOutput(recordUrl)
|
||||
logger.info('')
|
||||
logger.info(
|
||||
playwrightFailure !== null
|
||||
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
||||
: 'Recording finished, rendering in progress. Results available at:'
|
||||
)
|
||||
logger.info(pc.cyan(recordUrl))
|
||||
} else if (projectId !== null) {
|
||||
const projectUrl = `${appUrl}/project/${projectId}`
|
||||
await writeGitHubProjectOutput(projectUrl)
|
||||
logger.info('')
|
||||
@@ -2679,6 +2726,7 @@ async function run(
|
||||
}
|
||||
|
||||
function logRecordFailureHint(): void {
|
||||
logger.info('')
|
||||
logger.info(
|
||||
`If ${pc.cyan('screenci test')} works but ${pc.cyan(
|
||||
'screenci record'
|
||||
@@ -2687,6 +2735,22 @@ function logRecordFailureHint(): void {
|
||||
logger.info(`More info: ${pc.cyan(SCREENCI_MOCK_RECORD_DOCS_URL)}`)
|
||||
}
|
||||
|
||||
export function logCliError(error: unknown): void {
|
||||
if (isPartialUploadError(error)) {
|
||||
return
|
||||
}
|
||||
|
||||
const errorToLog = isRecordFailureHintError(error) ? error.cause : error
|
||||
const message =
|
||||
errorToLog instanceof Error ? errorToLog.message : String(errorToLog)
|
||||
|
||||
logger.error(message)
|
||||
|
||||
if (isRecordFailureHintError(error)) {
|
||||
logRecordFailureHint()
|
||||
}
|
||||
}
|
||||
|
||||
// Only run if this file is being executed directly
|
||||
// Check if this module is the main module (handles symlinks properly)
|
||||
const currentFile = fileURLToPath(import.meta.url)
|
||||
@@ -2700,10 +2764,7 @@ if (
|
||||
currentFile === realpathSync(mainFile))
|
||||
) {
|
||||
main().catch((error) => {
|
||||
if (isPartialUploadError(error)) {
|
||||
process.exit(1)
|
||||
}
|
||||
logger.error('Error:', error.message)
|
||||
logCliError(error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -390,6 +390,11 @@ These commands support `--config <path>`:
|
||||
If `envFile` is configured in `screenci.config.ts`, the CLI loads it
|
||||
automatically. Otherwise it falls back to the project `.env`.
|
||||
|
||||
That env file is the recommended place to keep local ScreenCI secrets and other
|
||||
runtime variables your setup needs. For example, if your local workflow depends
|
||||
on an ElevenLabs BYOK key, keep `ELEVENLABS_API_KEY` there instead of hardcoding
|
||||
it elsewhere. ScreenCI does not store raw API keys from that file.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Configuration](/docs/reference/configuration) for `screenci.config.ts`.
|
||||
|
||||
+72
-4
@@ -75,9 +75,40 @@ Use this as a menu, not a template you must fill out. Most projects only need
|
||||
### Project identity
|
||||
|
||||
- `projectName` identifies the project in ScreenCI.
|
||||
- `envFile` points to the file that holds `SCREENCI_SECRET` and related
|
||||
variables.
|
||||
- If `envFile` is omitted, ScreenCI falls back to the usual process environment.
|
||||
- `envFile` points to the file that holds `SCREENCI_SECRET` and other local
|
||||
runtime variables your ScreenCI workflow needs.
|
||||
- If `envFile` is configured, ScreenCI loads it automatically.
|
||||
- If `envFile` is omitted, ScreenCI falls back to the project `.env`.
|
||||
|
||||
For example, keep `SCREENCI_SECRET` there, and keep any local BYOK secrets such
|
||||
as `ELEVENLABS_API_KEY` there when your local ScreenCI or backend setup depends
|
||||
on them. ScreenCI does not store raw API keys from your env file.
|
||||
|
||||
### Example: `.env` file
|
||||
|
||||
A typical local env file looks like this:
|
||||
|
||||
```bash
|
||||
SCREENCI_SECRET=sc_live_your_project_secret
|
||||
ELEVENLABS_API_KEY=sk_your_elevenlabs_key
|
||||
GOOGLE_CLOUD_API_KEY=your_google_cloud_key
|
||||
GOOGLE_VERTEX_SERVICE_ACCOUNT={"project_id":"my-project","client_email":"...","private_key":"..."}
|
||||
GOOGLE_VERTEX_LOCATION=us-central1
|
||||
```
|
||||
|
||||
Common cases:
|
||||
|
||||
- `SCREENCI_SECRET` authenticates `screenci record`, `screenci info`, and
|
||||
public visibility commands.
|
||||
- `ELEVENLABS_API_KEY` is required when your narration uses
|
||||
`voices.elevenlabs({ voiceId })` or custom voice assets.
|
||||
- `GOOGLE_CLOUD_API_KEY` is used for consistent model-backed narration.
|
||||
- `GOOGLE_VERTEX_SERVICE_ACCOUNT` and `GOOGLE_VERTEX_LOCATION` are used for
|
||||
expressive Gemini narration.
|
||||
|
||||
Keep adding local runtime secrets here as needed. `screenci.config.ts` only
|
||||
points to the env file; the actual secret values belong in `.env` or whatever
|
||||
file you set via `envFile`.
|
||||
|
||||
### File locations
|
||||
|
||||
@@ -115,6 +146,42 @@ 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.
|
||||
|
||||
### Example: shared `use` defaults
|
||||
|
||||
Use `use` when multiple videos should share the same recording, navigation, or
|
||||
rendering defaults:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'screenci'
|
||||
|
||||
export default defineConfig({
|
||||
projectName: 'my-product',
|
||||
envFile: '.env',
|
||||
use: {
|
||||
baseURL: 'https://staging.example.com',
|
||||
recordOptions: {
|
||||
aspectRatio: '16:9',
|
||||
quality: '1080p',
|
||||
fps: 60,
|
||||
},
|
||||
renderOptions: {
|
||||
narration: {
|
||||
corner: 'bottom-left',
|
||||
size: 'medium',
|
||||
},
|
||||
output: {
|
||||
background: {
|
||||
backgroundCss: 'linear-gradient(135deg, #0f172a 0%, #1d4ed8 100%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This keeps every video in the project on the same baseline, so individual files
|
||||
only need to override the few things that are actually different.
|
||||
|
||||
### Playwright integration
|
||||
|
||||
ScreenCI passes through most normal Playwright config such as:
|
||||
@@ -193,4 +260,5 @@ real workflow.
|
||||
## Related pages
|
||||
|
||||
- [CLI](/docs/reference/cli) for how config is discovered and loaded.
|
||||
for the runtime helpers used inside scripts.
|
||||
- [Narration and Localization](/docs/narration-and-localization) for cue and
|
||||
voice authoring.
|
||||
|
||||
@@ -51,6 +51,40 @@ Use the cue methods intentionally:
|
||||
That is the main tool for overlapping speech with UI motion without losing
|
||||
control of the timeline.
|
||||
|
||||
Keep cues small. In practice, one sentence per cue is the safest default for
|
||||
timing, overlap control, and subtitle readability.
|
||||
|
||||
If only one file needs a different narration layout, pair your cue definitions
|
||||
with `video.use()` instead of changing the whole project:
|
||||
|
||||
```ts
|
||||
import { createNarration, video, voices } from 'screenci'
|
||||
|
||||
video.use({
|
||||
renderOptions: {
|
||||
narration: {
|
||||
corner: 'top-right',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const narration = createNarration({
|
||||
voice: { name: voices.Sophie },
|
||||
en: {
|
||||
intro: 'Open the analytics tab.',
|
||||
summary: 'Review the latest numbers.',
|
||||
},
|
||||
})
|
||||
|
||||
video('Analytics walkthrough', async ({ page }) => {
|
||||
await narration.intro.start()
|
||||
await page.getByRole('tab', { name: 'Analytics' }).click()
|
||||
await narration.intro.end()
|
||||
|
||||
await narration.summary()
|
||||
})
|
||||
```
|
||||
|
||||
## Add localization
|
||||
|
||||
Add more languages by keeping the same cue keys:
|
||||
@@ -109,6 +143,10 @@ narration.
|
||||
Use the top-level `voice` as the default and override only the languages that
|
||||
genuinely need a different voice or delivery profile.
|
||||
|
||||
When you use ElevenLabs-specific voices or custom voice assets, keep each cue as
|
||||
its own sentence-sized unit. That makes re-recording cheaper and avoids paying
|
||||
to regenerate long blocks when only one line changes.
|
||||
|
||||
## Available voices
|
||||
|
||||
ScreenCI ships with built-in voices that you can use across supported languages
|
||||
@@ -239,3 +277,44 @@ Use `modelType` when you need to choose between consistency and expressiveness.
|
||||
- `consistent` is the safer default for docs and product walkthroughs
|
||||
- `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
|
||||
|
||||
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.
|
||||
|
||||
Use `voices.elevenlabs({ voiceId })` when you want to target a specific
|
||||
ElevenLabs voice from your own account:
|
||||
|
||||
```ts
|
||||
import { createNarration, video, voices } from 'screenci'
|
||||
|
||||
const narration = createNarration({
|
||||
en: {
|
||||
voice: {
|
||||
name: voices.elevenlabs({ voiceId: 'tMvyQtpCVQ0DkixuYm6J' }),
|
||||
},
|
||||
intro: 'Welcome to the dashboard.',
|
||||
details: 'Open settings to review billing details.',
|
||||
},
|
||||
})
|
||||
|
||||
video('Billing walkthrough', async ({ page }) => {
|
||||
await narration.intro()
|
||||
|
||||
await narration.details.start()
|
||||
await page.goto('/settings')
|
||||
await page.getByRole('button', { name: 'Open billing' }).click()
|
||||
await narration.details.end()
|
||||
})
|
||||
```
|
||||
|
||||
Replace the `voiceId` with the voice from your ElevenLabs account. For the
|
||||
env-file setup, see [Configuration](/docs/reference/configuration).
|
||||
|
||||
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.
|
||||
|
||||
@@ -207,6 +207,7 @@ Common options:
|
||||
- per-language `voice` overrides when one language needs a different voice
|
||||
- cue entries as text or file-based entries, depending on how you want to
|
||||
source narration
|
||||
- short, sentence-sized cues instead of paragraph-sized narration blocks
|
||||
|
||||
```ts
|
||||
const narration = createNarration({
|
||||
@@ -233,5 +234,10 @@ video('Billing walkthrough', async ({ page }) => {
|
||||
})
|
||||
```
|
||||
|
||||
Prefer one sentence per cue. Split longer narration into separate named cues and
|
||||
place them where they belong in the flow. That gives you cleaner overlap
|
||||
control, makes revisions less brittle, and should save API cost when a TTS
|
||||
provider such as ElevenLabs only needs to regenerate one changed sentence.
|
||||
|
||||
API reference: [createNarration()](/docs/reference/api/functions/createnarration),
|
||||
[voices](/docs/reference/api/variables/voices)
|
||||
|
||||
@@ -229,6 +229,38 @@ describe('createNarration', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('throws during createNarration when an ElevenLabs voice is configured without ELEVENLABS_API_KEY', () => {
|
||||
delete process.env.ELEVENLABS_API_KEY
|
||||
|
||||
expect(() =>
|
||||
createNarration({
|
||||
voice: {
|
||||
name: voices.elevenlabs({ voiceId: 'tMvyQtpCVQ0DkixuYm6J' }),
|
||||
},
|
||||
en: {
|
||||
intro: 'Hello world',
|
||||
},
|
||||
})
|
||||
).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.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws during createNarration when a custom voice ref is configured without ELEVENLABS_API_KEY', () => {
|
||||
delete process.env.ELEVENLABS_API_KEY
|
||||
|
||||
expect(() =>
|
||||
createNarration({
|
||||
voice: { name: { path: './voice.mp3' } },
|
||||
en: {
|
||||
intro: 'Hello world',
|
||||
},
|
||||
})
|
||||
).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.'
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when a video cue name is reused in one recording', async () => {
|
||||
const first = createNarration({
|
||||
voice: { name: voices.Ava },
|
||||
@@ -648,6 +680,7 @@ describe('createNarration', () => {
|
||||
})
|
||||
|
||||
it('allows custom voice refs before validation and resolves them at start', async () => {
|
||||
process.env.ELEVENLABS_API_KEY = 'test-elevenlabs-key'
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'screenci-voice-'))
|
||||
|
||||
try {
|
||||
|
||||
+24
@@ -30,6 +30,8 @@ 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'
|
||||
|
||||
// Blocking sleep — spin until the elapsed time has passed
|
||||
let sleepFn = (ms: number): void => {
|
||||
@@ -107,6 +109,23 @@ async function toRecordedVoice(
|
||||
}
|
||||
}
|
||||
|
||||
function usesElevenLabsVoice(voice: VoiceKey | CustomVoiceRef): boolean {
|
||||
if (isCustomVoiceRef(voice)) return true
|
||||
return voice.startsWith('elevenlabs:')
|
||||
}
|
||||
|
||||
function assertElevenLabsApiKeyConfigured(
|
||||
voice: VoiceKey | CustomVoiceRef,
|
||||
location: string
|
||||
): void {
|
||||
if (!usesElevenLabsVoice(voice)) return
|
||||
if (process.env.ELEVENLABS_API_KEY?.trim()) return
|
||||
|
||||
throw new Error(
|
||||
`${location} uses an ElevenLabs voice, but ELEVENLABS_API_KEY is not set. Add ELEVENLABS_API_KEY to your env file or process environment. See ${ELEVENLABS_DOCS_URL}.`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-ends any currently active cue before starting a new one.
|
||||
* Called internally at the start of every narration controller.
|
||||
@@ -532,6 +551,11 @@ function buildCuesFromInput(
|
||||
? 'expressive'
|
||||
: (langOverride?.modelType ?? topVoice.modelType)
|
||||
|
||||
assertElevenLabsApiKeyConfigured(
|
||||
effectiveVoiceName,
|
||||
`createNarration(${lang})`
|
||||
)
|
||||
|
||||
resolvedVoices.set(lang, effectiveVoiceName)
|
||||
resolvedVoiceMeta.set(lang, {
|
||||
name: voiceToKeyString(effectiveVoiceName),
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { RENDER_OPTIONS_DEFAULTS } from './types.js'
|
||||
import type { VoiceKey } from './voices.js'
|
||||
import { DEFAULT_ZOOM_OPTIONS } from './defaults.js'
|
||||
import { getGitMetadata } from './git.js'
|
||||
|
||||
function assertAutoZoomUnitIntervalOption(
|
||||
value: number,
|
||||
@@ -859,6 +860,8 @@ export class EventRecorder implements IEventRecorder {
|
||||
}
|
||||
const languages = languageSet.size > 0 ? [...languageSet].sort() : undefined
|
||||
|
||||
const git = getGitMetadata()
|
||||
|
||||
const data: RecordingData = {
|
||||
events: this.events,
|
||||
renderOptions: resolved,
|
||||
@@ -870,6 +873,8 @@ export class EventRecorder implements IEventRecorder {
|
||||
screenciVersion: SCREENCI_VERSION,
|
||||
...(languages !== undefined && { languages }),
|
||||
...(sourceFilePath !== undefined && { sourceFilePath }),
|
||||
...(git.commit !== undefined && { commit: git.commit }),
|
||||
...(git.isDirty !== undefined && { isDirty: git.isDirty }),
|
||||
},
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify(data, null, 2))
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockExecFileSync = vi.fn<(cmd: string, args: string[]) => string>()
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execFileSync: (cmd: string, args: string[]) => mockExecFileSync(cmd, args),
|
||||
}))
|
||||
|
||||
// Import after the mock is registered.
|
||||
const { getGitMetadata } = await import('./git.js')
|
||||
|
||||
const CI_VARS = [
|
||||
'CI',
|
||||
'CONTINUOUS_INTEGRATION',
|
||||
'GITHUB_ACTIONS',
|
||||
'GITLAB_CI',
|
||||
'BUILDKITE',
|
||||
'CIRCLECI',
|
||||
]
|
||||
|
||||
const savedEnv: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
for (const key of CI_VARS) {
|
||||
savedEnv[key] = process.env[key]
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of CI_VARS) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = savedEnv[key]
|
||||
}
|
||||
})
|
||||
|
||||
describe('getGitMetadata', () => {
|
||||
it('returns the 8-char commit and isDirty=true for a dirty repo', () => {
|
||||
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||
if (args[0] === 'rev-parse') return 'abcd1234ef567890\n'
|
||||
if (args[0] === 'status') return ' M src/foo.ts\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
expect(getGitMetadata()).toEqual({ commit: 'abcd1234', isDirty: true })
|
||||
})
|
||||
|
||||
it('returns isDirty=false for a clean repo', () => {
|
||||
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||
if (args[0] === 'rev-parse') return 'abcd1234ef567890\n'
|
||||
if (args[0] === 'status') return ''
|
||||
return ''
|
||||
})
|
||||
|
||||
expect(getGitMetadata()).toEqual({ commit: 'abcd1234', isDirty: false })
|
||||
})
|
||||
|
||||
it('reports isDirty=false in CI even when the working tree is dirty', () => {
|
||||
process.env.CI = 'true'
|
||||
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||
if (args[0] === 'rev-parse') return 'abcd1234ef567890\n'
|
||||
if (args[0] === 'status') return ' M src/foo.ts\n'
|
||||
return ''
|
||||
})
|
||||
|
||||
const result = getGitMetadata()
|
||||
expect(result).toEqual({ commit: 'abcd1234', isDirty: false })
|
||||
// status must not even be consulted in CI
|
||||
expect(
|
||||
mockExecFileSync.mock.calls.some((call) => call[1][0] === 'status')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns an empty object when git is unavailable / not a repo', () => {
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error('not a git repository')
|
||||
})
|
||||
|
||||
expect(getGitMetadata()).toEqual({})
|
||||
})
|
||||
|
||||
it('still returns the commit when the dirty check fails', () => {
|
||||
mockExecFileSync.mockImplementation((_cmd, args) => {
|
||||
if (args[0] === 'rev-parse') return 'abcd1234ef567890\n'
|
||||
throw new Error('status failed')
|
||||
})
|
||||
|
||||
expect(getGitMetadata()).toEqual({ commit: 'abcd1234' })
|
||||
})
|
||||
})
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
export type GitMetadata = {
|
||||
/** First 8 characters of the current commit hash, when in a git repo. */
|
||||
commit?: string
|
||||
/**
|
||||
* True when the working tree has uncommitted changes. Always false in CI
|
||||
* (CI checkouts are treated as clean). Omitted when it cannot be determined.
|
||||
*/
|
||||
isDirty?: boolean
|
||||
}
|
||||
|
||||
/** Treat common CI environments as always-clean. */
|
||||
function isCI(): boolean {
|
||||
const env = process.env
|
||||
return Boolean(
|
||||
env.CI ||
|
||||
env.CONTINUOUS_INTEGRATION ||
|
||||
env.GITHUB_ACTIONS ||
|
||||
env.GITLAB_CI ||
|
||||
env.BUILDKITE ||
|
||||
env.CIRCLECI
|
||||
)
|
||||
}
|
||||
|
||||
function runGit(args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort git metadata for the recording. Never throws — returns an empty
|
||||
* object when git is unavailable or the directory is not a repository.
|
||||
*/
|
||||
export function getGitMetadata(): GitMetadata {
|
||||
try {
|
||||
const commit = runGit(['rev-parse', 'HEAD']).slice(0, 8)
|
||||
if (commit.length === 0) return {}
|
||||
|
||||
// In CI the checkout is considered clean regardless of working tree state.
|
||||
if (isCI()) return { commit, isDirty: false }
|
||||
|
||||
try {
|
||||
const status = runGit(['status', '--porcelain'])
|
||||
return { commit, isDirty: status.length > 0 }
|
||||
} catch {
|
||||
// Could not determine dirtiness; still report the commit.
|
||||
return { commit }
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,10 @@ export type RecordingMetadata = {
|
||||
screenciVersion: string
|
||||
languages?: string[]
|
||||
sourceFilePath?: string
|
||||
/** First 8 chars of the git commit the recording was made at, if available. */
|
||||
commit?: string
|
||||
/** Whether the repo had uncommitted changes (always false in CI). */
|
||||
isDirty?: boolean
|
||||
}
|
||||
|
||||
export type RecordingData = {
|
||||
|
||||
Reference in New Issue
Block a user