screenci 0.0.75 → 0.0.78
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +62 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +699 -229
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +277 -91
- package/dist/docs/manifest.d.ts.map +1 -1
- package/dist/docs/manifest.js +101 -30
- package/dist/docs/manifest.js.map +1 -1
- package/dist/docs/video-sources/camera-and-zooming.screenci.d.ts +2 -0
- package/dist/docs/video-sources/camera-and-zooming.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/camera-and-zooming.screenci.js +57 -0
- package/dist/docs/video-sources/camera-and-zooming.screenci.js.map +1 -0
- package/dist/docs/video-sources/installation.screenci.js +19 -10
- package/dist/docs/video-sources/installation.screenci.js.map +1 -1
- package/dist/docs/video-sources/landing.screenci.js +6 -8
- package/dist/docs/video-sources/landing.screenci.js.map +1 -1
- package/dist/docs/video-sources/languages.screenci.d.ts +2 -0
- package/dist/docs/video-sources/languages.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/languages.screenci.js +68 -0
- package/dist/docs/video-sources/languages.screenci.js.map +1 -0
- package/dist/docs/video-sources/locator-highlight-animated.screenci.js +4 -5
- package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -1
- package/dist/docs/video-sources/locator-highlight-still.screenci.js +3 -4
- package/dist/docs/video-sources/locator-highlight-still.screenci.js.map +1 -1
- package/dist/docs/video-sources/narration.screenci.d.ts +2 -0
- package/dist/docs/video-sources/narration.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/narration.screenci.js +71 -0
- package/dist/docs/video-sources/narration.screenci.js.map +1 -0
- package/dist/docs/video-sources/public-urls-and-embeds.screenci.d.ts +2 -0
- package/dist/docs/video-sources/public-urls-and-embeds.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/public-urls-and-embeds.screenci.js +60 -0
- package/dist/docs/video-sources/public-urls-and-embeds.screenci.js.map +1 -0
- package/dist/docs/video-sources/speaking-style.screenci.d.ts +2 -0
- package/dist/docs/video-sources/speaking-style.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/speaking-style.screenci.js +76 -0
- package/dist/docs/video-sources/speaking-style.screenci.js.map +1 -0
- package/dist/docs/video-sources/studio.screenci.js +8 -10
- package/dist/docs/video-sources/studio.screenci.js.map +1 -1
- package/dist/docs/video-sources/vertical-video.screenci.d.ts +2 -0
- package/dist/docs/video-sources/vertical-video.screenci.d.ts.map +1 -0
- package/dist/docs/video-sources/vertical-video.screenci.js +58 -0
- package/dist/docs/video-sources/vertical-video.screenci.js.map +1 -0
- package/dist/docs/videos.d.ts +20 -0
- package/dist/docs/videos.d.ts.map +1 -1
- package/dist/docs/videos.js +25 -0
- package/dist/docs/videos.js.map +1 -1
- package/dist/e2e/instrument.e2e.js +60 -1
- package/dist/e2e/instrument.e2e.js.map +1 -1
- package/dist/e2e/redact.e2e.d.ts +2 -0
- package/dist/e2e/redact.e2e.d.ts.map +1 -0
- package/dist/e2e/redact.e2e.js +161 -0
- package/dist/e2e/redact.e2e.js.map +1 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/src/asset.d.ts +174 -14
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +320 -39
- package/dist/src/asset.js.map +1 -1
- package/dist/src/assetHash.d.ts +27 -0
- package/dist/src/assetHash.d.ts.map +1 -0
- package/dist/src/assetHash.js +0 -0
- package/dist/src/assetHash.js.map +1 -0
- package/dist/src/audio.d.ts +20 -1
- package/dist/src/audio.d.ts.map +1 -1
- package/dist/src/audio.js +126 -24
- package/dist/src/audio.js.map +1 -1
- package/dist/src/autoZoom.d.ts.map +1 -1
- package/dist/src/autoZoom.js +3 -1
- package/dist/src/autoZoom.js.map +1 -1
- package/dist/src/browserLaunchOptions.d.ts +24 -2
- package/dist/src/browserLaunchOptions.d.ts.map +1 -1
- package/dist/src/browserLaunchOptions.js +57 -3
- package/dist/src/browserLaunchOptions.js.map +1 -1
- package/dist/src/builder.d.ts +229 -136
- package/dist/src/builder.d.ts.map +1 -1
- package/dist/src/builder.js +264 -77
- package/dist/src/builder.js.map +1 -1
- package/dist/src/callerFile.d.ts +12 -0
- package/dist/src/callerFile.d.ts.map +1 -0
- package/dist/src/callerFile.js +46 -0
- package/dist/src/callerFile.js.map +1 -0
- package/dist/src/changeFocus.d.ts.map +1 -1
- package/dist/src/changeFocus.js +109 -36
- package/dist/src/changeFocus.js.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +15 -0
- package/dist/src/config.js.map +1 -1
- package/dist/src/cue.d.ts +29 -17
- package/dist/src/cue.d.ts.map +1 -1
- package/dist/src/cue.js +138 -61
- package/dist/src/cue.js.map +1 -1
- package/dist/src/declare.d.ts +63 -0
- package/dist/src/declare.d.ts.map +1 -0
- package/dist/src/declare.js +111 -0
- package/dist/src/declare.js.map +1 -0
- package/dist/src/defaults.d.ts +23 -0
- package/dist/src/defaults.d.ts.map +1 -1
- package/dist/src/defaults.js +27 -3
- package/dist/src/defaults.js.map +1 -1
- package/dist/src/events.d.ts +339 -25
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +250 -26
- package/dist/src/events.js.map +1 -1
- package/dist/src/htmlRasterizer.d.ts +10 -0
- package/dist/src/htmlRasterizer.d.ts.map +1 -1
- package/dist/src/htmlRasterizer.js +12 -2
- package/dist/src/htmlRasterizer.js.map +1 -1
- package/dist/src/init.d.ts +2 -0
- package/dist/src/init.d.ts.map +1 -1
- package/dist/src/init.js +81 -33
- package/dist/src/init.js.map +1 -1
- package/dist/src/instrument.d.ts.map +1 -1
- package/dist/src/instrument.js +164 -8
- package/dist/src/instrument.js.map +1 -1
- package/dist/src/locales.d.ts.map +1 -1
- package/dist/src/locales.js +1 -0
- package/dist/src/locales.js.map +1 -1
- package/dist/src/localize.d.ts +26 -17
- package/dist/src/localize.d.ts.map +1 -1
- package/dist/src/localize.js +19 -11
- package/dist/src/localize.js.map +1 -1
- package/dist/src/localizeRuntime.d.ts +29 -33
- package/dist/src/localizeRuntime.d.ts.map +1 -1
- package/dist/src/localizeRuntime.js +74 -49
- package/dist/src/localizeRuntime.js.map +1 -1
- package/dist/src/logger.d.ts +5 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +7 -0
- package/dist/src/logger.js.map +1 -1
- package/dist/src/manualZoom.d.ts.map +1 -1
- package/dist/src/manualZoom.js +8 -5
- package/dist/src/manualZoom.js.map +1 -1
- package/dist/src/narrationVisibility.d.ts +19 -0
- package/dist/src/narrationVisibility.d.ts.map +1 -0
- package/dist/src/narrationVisibility.js +26 -0
- package/dist/src/narrationVisibility.js.map +1 -0
- package/dist/src/openBrowser.d.ts +34 -0
- package/dist/src/openBrowser.d.ts.map +1 -0
- package/dist/src/openBrowser.js +62 -0
- package/dist/src/openBrowser.js.map +1 -0
- package/dist/src/recordingData.d.ts +27 -0
- package/dist/src/recordingData.d.ts.map +1 -1
- package/dist/src/recordingSize.d.ts +32 -0
- package/dist/src/recordingSize.d.ts.map +1 -0
- package/dist/src/recordingSize.js +54 -0
- package/dist/src/recordingSize.js.map +1 -0
- package/dist/src/redact.d.ts +68 -0
- package/dist/src/redact.d.ts.map +1 -0
- package/dist/src/redact.js +138 -0
- package/dist/src/redact.js.map +1 -0
- package/dist/src/redactController.d.ts +61 -0
- package/dist/src/redactController.d.ts.map +1 -0
- package/dist/src/redactController.js +232 -0
- package/dist/src/redactController.js.map +1 -0
- package/dist/src/runtimeContext.d.ts +32 -0
- package/dist/src/runtimeContext.d.ts.map +1 -1
- package/dist/src/runtimeContext.js +21 -0
- package/dist/src/runtimeContext.js.map +1 -1
- package/dist/src/runtimeMode.d.ts +15 -7
- package/dist/src/runtimeMode.d.ts.map +1 -1
- package/dist/src/runtimeMode.js +17 -6
- package/dist/src/runtimeMode.js.map +1 -1
- package/dist/src/screenAudio.d.ts +88 -0
- package/dist/src/screenAudio.d.ts.map +1 -0
- package/dist/src/screenAudio.js +0 -0
- package/dist/src/screenAudio.js.map +1 -0
- package/dist/src/screenAudioSink.d.ts +46 -0
- package/dist/src/screenAudioSink.d.ts.map +1 -0
- package/dist/src/screenAudioSink.js +77 -0
- package/dist/src/screenAudioSink.js.map +1 -0
- package/dist/src/screenshot.d.ts +27 -29
- package/dist/src/screenshot.d.ts.map +1 -1
- package/dist/src/screenshot.js +39 -24
- package/dist/src/screenshot.js.map +1 -1
- package/dist/src/sourceTrim.d.ts +29 -0
- package/dist/src/sourceTrim.d.ts.map +1 -0
- package/dist/src/sourceTrim.js +76 -0
- package/dist/src/sourceTrim.js.map +1 -0
- package/dist/src/stillCapture.d.ts +5 -0
- package/dist/src/stillCapture.d.ts.map +1 -1
- package/dist/src/stillCapture.js +11 -1
- package/dist/src/stillCapture.js.map +1 -1
- package/dist/src/studio.d.ts +69 -57
- package/dist/src/studio.d.ts.map +1 -1
- package/dist/src/studio.js +16 -38
- package/dist/src/studio.js.map +1 -1
- package/dist/src/timelineOffset.d.ts +46 -0
- package/dist/src/timelineOffset.d.ts.map +1 -0
- package/dist/src/timelineOffset.js +66 -0
- package/dist/src/timelineOffset.js.map +1 -0
- package/dist/src/titleValidation.d.ts +38 -0
- package/dist/src/titleValidation.d.ts.map +1 -1
- package/dist/src/titleValidation.js +42 -0
- package/dist/src/titleValidation.js.map +1 -1
- package/dist/src/types.d.ts +218 -2
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/video.d.ts +74 -60
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +201 -39
- package/dist/src/video.js.map +1 -1
- package/dist/src/voiceSampleAudio.d.ts +34 -0
- package/dist/src/voiceSampleAudio.d.ts.map +1 -0
- package/dist/src/voiceSampleAudio.js +105 -0
- package/dist/src/voiceSampleAudio.js.map +1 -0
- package/dist/src/voices.d.ts +2 -2
- package/dist/src/voices.d.ts.map +1 -1
- package/dist/src/voices.js +8 -3
- package/dist/src/voices.js.map +1 -1
- package/dist/src/zoom.d.ts +1 -0
- package/dist/src/zoom.d.ts.map +1 -1
- package/dist/src/zoom.js +3 -0
- package/dist/src/zoom.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/skills/screenci/SKILL.md +2 -2
- package/skills/screenci/references/init.md +4 -3
- package/skills/screenci/references/record.md +1 -1
- package/templates/recordings/assets/logo.png +0 -0
- package/dist/docs/video-sources/public-urls.screenci.d.ts +0 -2
- package/dist/docs/video-sources/public-urls.screenci.d.ts.map +0 -1
- package/dist/docs/video-sources/public-urls.screenci.js +0 -41
- package/dist/docs/video-sources/public-urls.screenci.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -10,10 +10,13 @@ import { Command, CommanderError } from 'commander';
|
|
|
10
10
|
import pc from 'picocolors';
|
|
11
11
|
import { logger } from './src/logger.js';
|
|
12
12
|
import { determinePackageManager, parsePackageManager, runInit, } from './src/init.js';
|
|
13
|
-
import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, SCREENCI_LANGUAGES_ENV,
|
|
13
|
+
import { SCREENCI_DISABLE_RECORDING_TIMINGS_ENV, SCREENCI_MOCK_RECORD_ENV, SCREENCI_LANGUAGES_ENV, SCREENCI_VALUES_OVERRIDES_ENV, SCREENCI_RECORD_OPTIONS_ENV, isUploadExistingEnabled, } from './src/runtimeMode.js';
|
|
14
14
|
import { DEFAULT_RECORD_UPLOAD_POLICY } from './src/defaults.js';
|
|
15
15
|
import { findDuplicateTitles, formatDuplicateTitlesMessage, } from './src/titleValidation.js';
|
|
16
16
|
import { createLinkSessionSpec, deletePersistedLinkSessionSpec, getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl, getLinkSessionFilePath, getScreenCIEnvironment, isStoredLinkSessionReusable, readPersistedLinkSessionSpec, SCREENCI_LINK_SESSION_FILE, writePersistedLinkSessionSpec, } from './src/linkSession.js';
|
|
17
|
+
import { OVERLAY_CACHE_DIR_NAME } from './src/htmlRasterizer.js';
|
|
18
|
+
import { openUrlInBrowser } from './src/openBrowser.js';
|
|
19
|
+
import { maybeExtractVoiceSampleAudio } from './src/voiceSampleAudio.js';
|
|
17
20
|
// Re-export the environment-aware URL helpers so existing importers (and tests)
|
|
18
21
|
// can keep importing them from the CLI entrypoint.
|
|
19
22
|
export { getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl };
|
|
@@ -214,10 +217,22 @@ function contentTypeForPath(filePath) {
|
|
|
214
217
|
mp4: 'video/mp4',
|
|
215
218
|
webm: 'video/webm',
|
|
216
219
|
mp3: 'audio/mpeg',
|
|
220
|
+
wav: 'audio/wav',
|
|
221
|
+
m4a: 'audio/mp4',
|
|
222
|
+
aac: 'audio/aac',
|
|
217
223
|
svg: 'image/svg+xml',
|
|
218
224
|
};
|
|
219
225
|
return contentTypeMap[ext] ?? 'application/octet-stream';
|
|
220
226
|
}
|
|
227
|
+
/** Human label per upload kind, shown as `<label> uploaded: <path>`. */
|
|
228
|
+
const UPLOAD_ASSET_LABEL = {
|
|
229
|
+
overlay: 'Overlay',
|
|
230
|
+
audio: 'Audio',
|
|
231
|
+
// A `videoCueStart` asset: a media file placed on the narration timeline
|
|
232
|
+
// (e.g. via `narration({ en: { intro: { media } } })`).
|
|
233
|
+
clip: 'Narration',
|
|
234
|
+
voice: 'Voice',
|
|
235
|
+
};
|
|
221
236
|
export function formatStudioUrl(appUrl, projectId, videoId) {
|
|
222
237
|
// The video hub resolves `?studio` to the right language page and scrolls it
|
|
223
238
|
// to Studio, so we never need to guess the language in the printed link.
|
|
@@ -345,15 +360,19 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
|
|
|
345
360
|
}
|
|
346
361
|
const videoName = data.metadata?.videoName ?? entry;
|
|
347
362
|
const preparedUploadAssets = await collectUploadAssets(data, resolve(screenciDir, '..'));
|
|
363
|
+
// The recording data is annotated with asset hashes later, in
|
|
364
|
+
// uploadRecordingCandidate, after any locally missing assets have been
|
|
365
|
+
// resolved against a previous upload. Annotating here would strip the asset
|
|
366
|
+
// paths the resolve step needs to match on.
|
|
348
367
|
return {
|
|
349
368
|
entry,
|
|
350
369
|
videoName,
|
|
351
|
-
data
|
|
370
|
+
data,
|
|
352
371
|
preparedUploadAssets,
|
|
353
372
|
};
|
|
354
373
|
}
|
|
355
|
-
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret,
|
|
356
|
-
const { entry, videoName, data, preparedUploadAssets } = candidate;
|
|
374
|
+
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, progressIndex, recordId, expectedScreenshotCount) {
|
|
375
|
+
const { entry, videoName, data: rawData, preparedUploadAssets } = candidate;
|
|
357
376
|
let projectId = null;
|
|
358
377
|
let videoId = null;
|
|
359
378
|
let plan = null;
|
|
@@ -362,9 +381,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
362
381
|
// A screenshot recording uploads its raw page capture (always a PNG) through
|
|
363
382
|
// the same recording endpoint a video uses; the renderer reads those bytes as
|
|
364
383
|
// the capture. Videos upload recording.mp4. Output kind defaults to 'video'.
|
|
365
|
-
const isScreenshot =
|
|
384
|
+
const isScreenshot = rawData.output === 'screenshot';
|
|
366
385
|
const recordingFileName = isScreenshot
|
|
367
|
-
? (
|
|
386
|
+
? (rawData.screenshot?.path ?? 'screenshot.png')
|
|
368
387
|
: 'recording.mp4';
|
|
369
388
|
const recordingContentType = isScreenshot ? 'image/png' : 'video/mp4';
|
|
370
389
|
const recordingPath = resolve(screenciDir, entry, recordingFileName);
|
|
@@ -379,15 +398,29 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
379
398
|
recordId,
|
|
380
399
|
};
|
|
381
400
|
}
|
|
401
|
+
// Locally missing assets (e.g. gitignored media on CI) carry no bytes and,
|
|
402
|
+
// for overlays, no hash. Recover their identity from a previous upload of
|
|
403
|
+
// this video before starting, so the recording data references them by hash
|
|
404
|
+
// and the backend existence check confirms they are still stored.
|
|
405
|
+
const unresolved = await resolveMissingUploadAssets(preparedUploadAssets, projectName, videoName, apiUrl, secret, uploadAbort.signal, progressReporter);
|
|
406
|
+
if (unresolved.length > 0) {
|
|
407
|
+
progressReporter.complete(progressIndex, 'failure');
|
|
408
|
+
return {
|
|
409
|
+
projectId: null,
|
|
410
|
+
videoId: null,
|
|
411
|
+
hadFailure: true,
|
|
412
|
+
videoName,
|
|
413
|
+
failureMessage: formatUnresolvedAssetMessage(videoName, unresolved),
|
|
414
|
+
recordId,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
const data = annotateRecordingDataWithAssetHashes(rawData, preparedUploadAssets);
|
|
382
418
|
const recordingHash = await hashFile(recordingPath);
|
|
383
419
|
const startResponse = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/start`, {
|
|
384
420
|
method: 'POST',
|
|
385
421
|
headers: {
|
|
386
422
|
'Content-Type': 'application/json',
|
|
387
423
|
'X-ScreenCI-Secret': secret,
|
|
388
|
-
...(elevenLabsApiKey
|
|
389
|
-
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
390
|
-
: {}),
|
|
391
424
|
},
|
|
392
425
|
body: JSON.stringify({
|
|
393
426
|
projectName,
|
|
@@ -395,6 +428,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
395
428
|
data,
|
|
396
429
|
recordingHash,
|
|
397
430
|
recordId,
|
|
431
|
+
...(isScreenshot ? { expectedScreenshotCount } : {}),
|
|
398
432
|
expectedAssets: preparedUploadAssets.map((asset) => ({
|
|
399
433
|
fileHash: asset.fileHash,
|
|
400
434
|
size: asset.size,
|
|
@@ -425,6 +459,16 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
425
459
|
videoId = startBody.videoId ?? null;
|
|
426
460
|
plan = startBody.plan ?? null;
|
|
427
461
|
const studio = startBody.studio;
|
|
462
|
+
// Render-dependency validation failures are reported on the upload response
|
|
463
|
+
// so the author sees them at record time. These are enforced server-side:
|
|
464
|
+
// the render for this video fails with the dependency error until fixed.
|
|
465
|
+
if (startBody.dependencyErrors && startBody.dependencyErrors.length > 0) {
|
|
466
|
+
for (const depError of startBody.dependencyErrors) {
|
|
467
|
+
logger.error(`Render dependency error in "${videoName}": ${depError.detail}. This render will fail until it is fixed.`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// The missing-ElevenLabs-key warning is surfaced once in the final summary
|
|
471
|
+
// (see uploadRecordings), where it is not overwritten by the upload spinner.
|
|
428
472
|
if (verbose) {
|
|
429
473
|
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
430
474
|
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
@@ -448,9 +492,6 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
448
492
|
'Content-Type': recordingContentType,
|
|
449
493
|
'Content-Length': String(fileStat.size),
|
|
450
494
|
'X-ScreenCI-Secret': secret,
|
|
451
|
-
...(elevenLabsApiKey
|
|
452
|
-
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
453
|
-
: {}),
|
|
454
495
|
},
|
|
455
496
|
body: stream,
|
|
456
497
|
signal: uploadAbort.signal,
|
|
@@ -470,7 +511,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
470
511
|
videoId,
|
|
471
512
|
hadFailure: true,
|
|
472
513
|
videoName,
|
|
473
|
-
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
514
|
+
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${extractBackendError(text)}${hint401(recordingResponse.status, secret)}`,
|
|
474
515
|
recordId,
|
|
475
516
|
...(plan !== null && { plan }),
|
|
476
517
|
};
|
|
@@ -485,6 +526,13 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
485
526
|
recordId,
|
|
486
527
|
...(studio !== undefined && { studio }),
|
|
487
528
|
...(plan !== null && { plan }),
|
|
529
|
+
...(startBody.elevenLabsKeyMissing === true && {
|
|
530
|
+
elevenLabsKeyMissing: true,
|
|
531
|
+
}),
|
|
532
|
+
...(Array.isArray(startBody.notices) &&
|
|
533
|
+
startBody.notices.length > 0 && {
|
|
534
|
+
notices: startBody.notices.filter((notice) => typeof notice === 'string'),
|
|
535
|
+
}),
|
|
488
536
|
};
|
|
489
537
|
}
|
|
490
538
|
catch (err) {
|
|
@@ -708,11 +756,17 @@ function forwardChildSignals(child, activityLabel, options = {}) {
|
|
|
708
756
|
getForwardedSignal: () => forwardedSignal,
|
|
709
757
|
};
|
|
710
758
|
}
|
|
711
|
-
function clearRecordingDirectories(dir) {
|
|
759
|
+
export function clearRecordingDirectories(dir) {
|
|
712
760
|
mkdirSync(dir, { recursive: true });
|
|
713
761
|
for (const entry of readdirSync(dir)) {
|
|
714
762
|
if (entry === SCREENCI_LINK_SESSION_FILE)
|
|
715
763
|
continue;
|
|
764
|
+
// Preserve the cross-run overlay cache: it lives as a sibling of the
|
|
765
|
+
// per-recording directories so unchanged overlays are served byte for byte
|
|
766
|
+
// from a previous run. Wiping it would re-render and re-encode every overlay
|
|
767
|
+
// each run, changing their content hashes and forcing a re-upload.
|
|
768
|
+
if (entry === OVERLAY_CACHE_DIR_NAME)
|
|
769
|
+
continue;
|
|
716
770
|
rmSync(resolve(dir, entry), { recursive: true, force: true });
|
|
717
771
|
}
|
|
718
772
|
}
|
|
@@ -821,32 +875,56 @@ async function prepareCustomVoiceAssets(data, configDir) {
|
|
|
821
875
|
const resolvedFile = await readRecordingFile(voicePath, configDir, sourceFilePath);
|
|
822
876
|
if (resolvedFile === null) {
|
|
823
877
|
const existingHash = refs.find((ref) => typeof ref.assetHash === 'string')?.assetHash;
|
|
824
|
-
if (
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
878
|
+
if (existingHash) {
|
|
879
|
+
// The recording already carries this voice's content hash, so reference
|
|
880
|
+
// it by that hash. The backend check confirms it is still stored.
|
|
881
|
+
for (const ref of refs) {
|
|
882
|
+
ref.assetHash = existingHash;
|
|
883
|
+
}
|
|
884
|
+
preparedAssets.push({
|
|
885
|
+
kind: 'voice',
|
|
886
|
+
fileHash: existingHash,
|
|
887
|
+
path: voicePath,
|
|
888
|
+
size: 0,
|
|
889
|
+
contentType: contentTypeForPath(voicePath),
|
|
890
|
+
assumedUploaded: true,
|
|
891
|
+
});
|
|
892
|
+
continue;
|
|
830
893
|
}
|
|
894
|
+
// No cached hash either. Recover the voice's identity from a previous
|
|
895
|
+
// upload of this video, matched by path. resolveMissingUploadAssets fills
|
|
896
|
+
// in the hash and writes it back onto the cue refs.
|
|
831
897
|
preparedAssets.push({
|
|
832
|
-
|
|
898
|
+
kind: 'voice',
|
|
899
|
+
fileHash: '',
|
|
833
900
|
path: voicePath,
|
|
834
901
|
size: 0,
|
|
835
902
|
contentType: contentTypeForPath(voicePath),
|
|
903
|
+
needsResolve: true,
|
|
836
904
|
});
|
|
837
905
|
continue;
|
|
838
906
|
}
|
|
839
907
|
const { buffer: fileBuffer, resolvedPath } = resolvedFile;
|
|
840
|
-
|
|
841
|
-
|
|
908
|
+
// Identify the voice by the ORIGINAL file, so the clone cache survives any
|
|
909
|
+
// re-encode of the uploaded bytes (see backend resolveCustomVoice).
|
|
910
|
+
const sampleHash = createHash('sha256').update(fileBuffer).digest('hex');
|
|
911
|
+
// A voice clone only needs audio, and the clone service caps samples at
|
|
912
|
+
// ~11 MB. For video containers (or any oversized file) strip to a small MP3
|
|
913
|
+
// before upload; otherwise upload the audio unchanged.
|
|
914
|
+
const extracted = await maybeExtractVoiceSampleAudio(resolvedPath, fileBuffer.byteLength);
|
|
915
|
+
const uploadBuffer = extracted?.buffer ?? fileBuffer;
|
|
916
|
+
const contentType = extracted?.contentType ?? contentTypeForPath(resolvedPath);
|
|
917
|
+
const assetHash = createHash('sha256').update(uploadBuffer).digest('hex');
|
|
842
918
|
for (const ref of refs) {
|
|
843
919
|
ref.assetHash = assetHash;
|
|
920
|
+
ref.sampleHash = sampleHash;
|
|
844
921
|
}
|
|
845
922
|
preparedAssets.push({
|
|
923
|
+
kind: 'voice',
|
|
846
924
|
fileHash: assetHash,
|
|
847
925
|
path: voicePath,
|
|
848
|
-
size:
|
|
849
|
-
fileBuffer,
|
|
926
|
+
size: uploadBuffer.byteLength,
|
|
927
|
+
fileBuffer: uploadBuffer,
|
|
850
928
|
contentType,
|
|
851
929
|
});
|
|
852
930
|
}
|
|
@@ -861,15 +939,29 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
861
939
|
// page and merged into the recording by the backend.
|
|
862
940
|
if ('studio' in event && event.studio === true)
|
|
863
941
|
continue;
|
|
942
|
+
// Render dependencies (selected(...)) have no local file: the backend
|
|
943
|
+
// resolves the target render's output and injects it at dispatch time.
|
|
944
|
+
if ('dependency' in event)
|
|
945
|
+
continue;
|
|
864
946
|
if (assets.has(`name:${event.name}`))
|
|
865
947
|
continue;
|
|
866
948
|
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
867
949
|
if (resolvedFile === null) {
|
|
868
|
-
|
|
950
|
+
// The local file is gone (e.g. gitignored media on CI). Reference it so
|
|
951
|
+
// its identity can be recovered from a previous upload of this video.
|
|
952
|
+
assets.set(`name:${event.name}`, {
|
|
953
|
+
kind: 'overlay',
|
|
954
|
+
fileHash: '',
|
|
955
|
+
path: event.path,
|
|
956
|
+
name: event.name,
|
|
957
|
+
size: 0,
|
|
958
|
+
needsResolve: true,
|
|
959
|
+
});
|
|
869
960
|
continue;
|
|
870
961
|
}
|
|
871
962
|
const { buffer: fileBuffer, resolvedPath } = resolvedFile;
|
|
872
963
|
assets.set(`name:${event.name}`, {
|
|
964
|
+
kind: 'overlay',
|
|
873
965
|
fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
|
|
874
966
|
path: event.path,
|
|
875
967
|
name: event.name,
|
|
@@ -879,6 +971,57 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
879
971
|
});
|
|
880
972
|
continue;
|
|
881
973
|
}
|
|
974
|
+
if (event.type === 'audioStart') {
|
|
975
|
+
// Studio audio tracks have no local file.
|
|
976
|
+
if ('studio' in event && event.studio === true)
|
|
977
|
+
continue;
|
|
978
|
+
// Prefer the record-time content hash as the dedup key; a missing local
|
|
979
|
+
// file may have been emitted without one, so fall back to the path.
|
|
980
|
+
const dedupKey = event.fileHash
|
|
981
|
+
? `hash:${event.fileHash}`
|
|
982
|
+
: `path:${event.path}`;
|
|
983
|
+
if (assets.has(dedupKey))
|
|
984
|
+
continue;
|
|
985
|
+
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
986
|
+
if (resolvedFile === null) {
|
|
987
|
+
// The local file is gone. If the recording still carries its content
|
|
988
|
+
// hash, reference it by that hash (the backend check confirms it is
|
|
989
|
+
// stored). Otherwise recover its identity from a previous upload by path.
|
|
990
|
+
// Captured screen audio (`__screen`) is per-recording and can never be
|
|
991
|
+
// recovered from a prior upload, so it is simply skipped when missing.
|
|
992
|
+
if (event.fileHash) {
|
|
993
|
+
assets.set(dedupKey, {
|
|
994
|
+
kind: 'audio',
|
|
995
|
+
fileHash: event.fileHash,
|
|
996
|
+
path: event.path,
|
|
997
|
+
size: 0,
|
|
998
|
+
assumedUploaded: true,
|
|
999
|
+
...(event.name === '__screen' && { alwaysUpload: true }),
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
else if (event.name !== '__screen') {
|
|
1003
|
+
assets.set(dedupKey, {
|
|
1004
|
+
kind: 'audio',
|
|
1005
|
+
fileHash: '',
|
|
1006
|
+
path: event.path,
|
|
1007
|
+
size: 0,
|
|
1008
|
+
needsResolve: true,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
assets.set(dedupKey, {
|
|
1014
|
+
kind: 'audio',
|
|
1015
|
+
fileHash: event.fileHash ??
|
|
1016
|
+
createHash('sha256').update(resolvedFile.buffer).digest('hex'),
|
|
1017
|
+
path: event.path,
|
|
1018
|
+
size: resolvedFile.buffer.byteLength,
|
|
1019
|
+
fileBuffer: resolvedFile.buffer,
|
|
1020
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
1021
|
+
...(event.name === '__screen' && { alwaysUpload: true }),
|
|
1022
|
+
});
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
882
1025
|
if (event.type === 'videoCueStart') {
|
|
883
1026
|
// Single-language: hash already computed during recording, use assetPath to read file
|
|
884
1027
|
if (typeof event.assetHash === 'string' &&
|
|
@@ -887,38 +1030,79 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
887
1030
|
? await readRecordingFile(event.assetPath, configDir, sourceFilePath)
|
|
888
1031
|
: null;
|
|
889
1032
|
assets.set(`hash:${event.assetHash}`, {
|
|
1033
|
+
kind: 'clip',
|
|
890
1034
|
fileHash: event.assetHash,
|
|
891
1035
|
path: event.assetPath ?? event.assetHash,
|
|
892
1036
|
size: resolvedFile?.buffer.byteLength ?? 0,
|
|
893
|
-
...(resolvedFile !== null
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1037
|
+
...(resolvedFile !== null
|
|
1038
|
+
? {
|
|
1039
|
+
fileBuffer: resolvedFile.buffer,
|
|
1040
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
1041
|
+
}
|
|
1042
|
+
: { assumedUploaded: true }),
|
|
897
1043
|
});
|
|
898
1044
|
}
|
|
1045
|
+
else if (typeof event.assetHash !== 'string' &&
|
|
1046
|
+
typeof event.assetPath === 'string' &&
|
|
1047
|
+
!assets.has(`path:${event.assetPath}`)) {
|
|
1048
|
+
// The media file was gone at record time, so it carries no hash. Recover
|
|
1049
|
+
// its identity from a previous upload of this video, matched by path.
|
|
1050
|
+
const resolvedFile = await readRecordingFile(event.assetPath, configDir, sourceFilePath);
|
|
1051
|
+
if (resolvedFile === null) {
|
|
1052
|
+
assets.set(`path:${event.assetPath}`, {
|
|
1053
|
+
kind: 'clip',
|
|
1054
|
+
fileHash: '',
|
|
1055
|
+
path: event.assetPath,
|
|
1056
|
+
size: 0,
|
|
1057
|
+
needsResolve: true,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
899
1061
|
// Multi-language: each translation carries its own hash
|
|
900
1062
|
if (event.translations) {
|
|
901
1063
|
for (const translation of Object.values(event.translations)) {
|
|
902
|
-
if (typeof translation
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
typeof translation.assetHash === 'string'
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1064
|
+
if (typeof translation !== 'object' || translation === null)
|
|
1065
|
+
continue;
|
|
1066
|
+
const assetHash = 'assetHash' in translation &&
|
|
1067
|
+
typeof translation.assetHash === 'string'
|
|
1068
|
+
? translation.assetHash
|
|
1069
|
+
: undefined;
|
|
1070
|
+
const assetPath = 'assetPath' in translation &&
|
|
1071
|
+
typeof translation.assetPath === 'string'
|
|
1072
|
+
? translation.assetPath
|
|
1073
|
+
: undefined;
|
|
1074
|
+
if (assetHash !== undefined) {
|
|
1075
|
+
if (assets.has(`hash:${assetHash}`))
|
|
1076
|
+
continue;
|
|
1077
|
+
const resolvedFile = assetPath !== undefined
|
|
1078
|
+
? await readRecordingFile(assetPath, configDir, sourceFilePath)
|
|
910
1079
|
: null;
|
|
911
|
-
assets.set(`hash:${
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1080
|
+
assets.set(`hash:${assetHash}`, {
|
|
1081
|
+
kind: 'clip',
|
|
1082
|
+
fileHash: assetHash,
|
|
1083
|
+
path: assetPath ?? assetHash,
|
|
915
1084
|
size: resolvedFile?.buffer.byteLength ?? 0,
|
|
916
|
-
...(resolvedFile !== null
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
1085
|
+
...(resolvedFile !== null
|
|
1086
|
+
? {
|
|
1087
|
+
fileBuffer: resolvedFile.buffer,
|
|
1088
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
1089
|
+
}
|
|
1090
|
+
: { assumedUploaded: true }),
|
|
920
1091
|
});
|
|
921
1092
|
}
|
|
1093
|
+
else if (assetPath !== undefined &&
|
|
1094
|
+
!assets.has(`path:${assetPath}`)) {
|
|
1095
|
+
const resolvedFile = await readRecordingFile(assetPath, configDir, sourceFilePath);
|
|
1096
|
+
if (resolvedFile === null) {
|
|
1097
|
+
assets.set(`path:${assetPath}`, {
|
|
1098
|
+
kind: 'clip',
|
|
1099
|
+
fileHash: '',
|
|
1100
|
+
path: assetPath,
|
|
1101
|
+
size: 0,
|
|
1102
|
+
needsResolve: true,
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
922
1106
|
}
|
|
923
1107
|
}
|
|
924
1108
|
}
|
|
@@ -928,23 +1112,47 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
928
1112
|
}
|
|
929
1113
|
return [...assets.values()];
|
|
930
1114
|
}
|
|
931
|
-
export function stripVoicePath(voice) {
|
|
1115
|
+
export function stripVoicePath(voice, byPath) {
|
|
932
1116
|
if (typeof voice !== 'string') {
|
|
933
|
-
|
|
1117
|
+
// A voice recovered from a previous upload has no record-time assetHash; fill
|
|
1118
|
+
// it in from the resolved-by-path map before the path is dropped.
|
|
1119
|
+
const assetHash = voice.assetHash ??
|
|
1120
|
+
(byPath !== undefined && typeof voice.assetPath === 'string'
|
|
1121
|
+
? byPath.get(voice.assetPath)
|
|
1122
|
+
: undefined);
|
|
1123
|
+
return { assetHash: assetHash };
|
|
934
1124
|
}
|
|
935
1125
|
return voice;
|
|
936
1126
|
}
|
|
937
1127
|
export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
1128
|
+
// Overlays are matched to their hash by name; every other asset kind (audio,
|
|
1129
|
+
// narration clip, custom voice) is matched by its file path. Skip placeholder
|
|
1130
|
+
// hashes that were never resolved so a missing entry stays untouched.
|
|
938
1131
|
const byName = new Map();
|
|
1132
|
+
const byPath = new Map();
|
|
939
1133
|
for (const asset of assets) {
|
|
1134
|
+
if (asset.fileHash.length === 0)
|
|
1135
|
+
continue;
|
|
940
1136
|
if (typeof asset.name === 'string')
|
|
941
1137
|
byName.set(asset.name, asset.fileHash);
|
|
1138
|
+
byPath.set(asset.path, asset.fileHash);
|
|
942
1139
|
}
|
|
943
1140
|
return {
|
|
944
1141
|
...data,
|
|
945
1142
|
events: data.events.map((event) => {
|
|
946
1143
|
if (event.type === 'assetStart') {
|
|
947
|
-
|
|
1144
|
+
if ('studio' in event || 'dependency' in event)
|
|
1145
|
+
return event;
|
|
1146
|
+
const fileHash = byName.get(event.name) ?? event.fileHash;
|
|
1147
|
+
return fileHash ? { ...event, fileHash } : event;
|
|
1148
|
+
}
|
|
1149
|
+
if (event.type === 'audioStart') {
|
|
1150
|
+
// Studio audio tracks carry no local path; leave them untouched.
|
|
1151
|
+
if (!('path' in event))
|
|
1152
|
+
return event;
|
|
1153
|
+
if (event.fileHash)
|
|
1154
|
+
return event;
|
|
1155
|
+
const fileHash = byPath.get(event.path);
|
|
948
1156
|
return fileHash ? { ...event, fileHash } : event;
|
|
949
1157
|
}
|
|
950
1158
|
if (event.type === 'cueStart' && event.translations) {
|
|
@@ -956,7 +1164,7 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
956
1164
|
language,
|
|
957
1165
|
{
|
|
958
1166
|
...translation,
|
|
959
|
-
voice: stripVoicePath(translation.voice),
|
|
1167
|
+
voice: stripVoicePath(translation.voice, byPath),
|
|
960
1168
|
},
|
|
961
1169
|
];
|
|
962
1170
|
}));
|
|
@@ -964,12 +1172,21 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
964
1172
|
}
|
|
965
1173
|
if (event.type !== 'videoCueStart')
|
|
966
1174
|
return event;
|
|
967
|
-
// Strip assetPath from translations
|
|
1175
|
+
// Strip assetPath from translations. The hash was either computed during
|
|
1176
|
+
// recording or recovered from a previous upload (matched by that path).
|
|
968
1177
|
if (event.translations) {
|
|
969
1178
|
const translations = Object.fromEntries(Object.entries(event.translations).map(([language, translation]) => {
|
|
970
|
-
if ('assetHash' in translation) {
|
|
971
|
-
const
|
|
972
|
-
|
|
1179
|
+
if ('assetHash' in translation || 'assetPath' in translation) {
|
|
1180
|
+
const file = translation;
|
|
1181
|
+
const assetHash = file.assetHash ??
|
|
1182
|
+
(file.assetPath !== undefined
|
|
1183
|
+
? byPath.get(file.assetPath)
|
|
1184
|
+
: undefined);
|
|
1185
|
+
const { assetPath: _removed, ...rest } = file;
|
|
1186
|
+
return [
|
|
1187
|
+
language,
|
|
1188
|
+
assetHash !== undefined ? { ...rest, assetHash } : rest,
|
|
1189
|
+
];
|
|
973
1190
|
}
|
|
974
1191
|
if ('voice' in translation) {
|
|
975
1192
|
return [
|
|
@@ -977,7 +1194,7 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
977
1194
|
{
|
|
978
1195
|
...translation,
|
|
979
1196
|
...(translation.voice !== undefined
|
|
980
|
-
? { voice: stripVoicePath(translation.voice) }
|
|
1197
|
+
? { voice: stripVoicePath(translation.voice, byPath) }
|
|
981
1198
|
: {}),
|
|
982
1199
|
},
|
|
983
1200
|
];
|
|
@@ -986,10 +1203,15 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
986
1203
|
}));
|
|
987
1204
|
return { ...event, translations };
|
|
988
1205
|
}
|
|
989
|
-
// Single-language:
|
|
990
|
-
|
|
1206
|
+
// Single-language: keep the assetHash (recovering it by path if needed) and
|
|
1207
|
+
// drop the now-redundant assetPath.
|
|
1208
|
+
const assetHash = event.assetHash ??
|
|
1209
|
+
(typeof event.assetPath === 'string'
|
|
1210
|
+
? byPath.get(event.assetPath)
|
|
1211
|
+
: undefined);
|
|
1212
|
+
if (typeof assetHash === 'string') {
|
|
991
1213
|
const { assetPath: _removed, ...rest } = event;
|
|
992
|
-
return rest;
|
|
1214
|
+
return { ...rest, assetHash };
|
|
993
1215
|
}
|
|
994
1216
|
return event;
|
|
995
1217
|
}),
|
|
@@ -999,7 +1221,27 @@ function hint401(status, secret) {
|
|
|
999
1221
|
if (status !== 401 || !secret)
|
|
1000
1222
|
return '';
|
|
1001
1223
|
const frontendUrl = getDevFrontendUrl();
|
|
1002
|
-
return `\nThe secret may have been deleted
|
|
1224
|
+
return `\nThe secret may have been deleted. Check your secrets at ${frontendUrl}/secrets`;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Reduces a backend error response to its human-readable message. Backend
|
|
1228
|
+
* failures reply with a JSON body like `{"error":"..."}`; this returns just the
|
|
1229
|
+
* `error` string so failures print the message, not the raw JSON. Non-JSON or
|
|
1230
|
+
* shapeless bodies fall back to the original text unchanged.
|
|
1231
|
+
*/
|
|
1232
|
+
export function extractBackendError(responseText) {
|
|
1233
|
+
if (responseText.trim().length === 0)
|
|
1234
|
+
return responseText;
|
|
1235
|
+
try {
|
|
1236
|
+
const parsed = JSON.parse(responseText);
|
|
1237
|
+
if (typeof parsed.error === 'string' && parsed.error.trim().length > 0) {
|
|
1238
|
+
return parsed.error;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
// Not JSON: fall back to the raw response text.
|
|
1243
|
+
}
|
|
1244
|
+
return responseText;
|
|
1003
1245
|
}
|
|
1004
1246
|
export function formatUploadStartFailureMessage(videoName, status, responseText, secret) {
|
|
1005
1247
|
if (responseText.trim().length > 0) {
|
|
@@ -1041,6 +1283,80 @@ export function displayAssetPath(assetPath) {
|
|
|
1041
1283
|
const rel = pathRelative(process.cwd(), assetPath);
|
|
1042
1284
|
return rel.length > 0 ? rel : assetPath;
|
|
1043
1285
|
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Recovers the identity of assets whose local file was absent during collection
|
|
1288
|
+
* (no bytes, and for overlays no hash) by matching them, by path or name,
|
|
1289
|
+
* against a previous upload of the same video. Each resolved asset is mutated in
|
|
1290
|
+
* place: its `fileHash`, `size`, and `contentType` are filled in and it is
|
|
1291
|
+
* flagged `assumedUploaded` so the later existence check confirms the bytes are
|
|
1292
|
+
* still stored rather than trying to upload absent bytes.
|
|
1293
|
+
*
|
|
1294
|
+
* Returns the assets that could not be resolved (no previous version), so the
|
|
1295
|
+
* caller can fail the recording with actionable guidance.
|
|
1296
|
+
*/
|
|
1297
|
+
export async function resolveMissingUploadAssets(assets, projectName, videoName, apiUrl, secret, signal, progressReporter) {
|
|
1298
|
+
const pending = assets.filter((asset) => asset.needsResolve === true);
|
|
1299
|
+
if (pending.length === 0)
|
|
1300
|
+
return [];
|
|
1301
|
+
const refs = pending.map((asset) => ({
|
|
1302
|
+
path: asset.path,
|
|
1303
|
+
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
1304
|
+
kind: asset.kind,
|
|
1305
|
+
}));
|
|
1306
|
+
const res = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/resolve-assets`, {
|
|
1307
|
+
method: 'POST',
|
|
1308
|
+
headers: {
|
|
1309
|
+
'Content-Type': 'application/json',
|
|
1310
|
+
'X-ScreenCI-Secret': secret,
|
|
1311
|
+
},
|
|
1312
|
+
body: JSON.stringify({ projectName, videoName, assets: refs }),
|
|
1313
|
+
signal,
|
|
1314
|
+
}), signal);
|
|
1315
|
+
if (!res.ok) {
|
|
1316
|
+
const text = await res.text();
|
|
1317
|
+
throw new UploadAssetError(`Failed to resolve previously uploaded assets for "${videoName}": ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1318
|
+
}
|
|
1319
|
+
const body = (await res.json());
|
|
1320
|
+
const resolved = Array.isArray(body.resolved) ? body.resolved : [];
|
|
1321
|
+
const logInfo = (message) => {
|
|
1322
|
+
if (progressReporter)
|
|
1323
|
+
progressReporter.info(message);
|
|
1324
|
+
else
|
|
1325
|
+
logger.info(message);
|
|
1326
|
+
};
|
|
1327
|
+
const unresolved = [];
|
|
1328
|
+
pending.forEach((asset, index) => {
|
|
1329
|
+
const match = resolved[index];
|
|
1330
|
+
if (match && typeof match.fileHash === 'string') {
|
|
1331
|
+
asset.fileHash = match.fileHash;
|
|
1332
|
+
asset.size = match.size ?? 0;
|
|
1333
|
+
if (typeof match.contentType === 'string') {
|
|
1334
|
+
asset.contentType = match.contentType;
|
|
1335
|
+
}
|
|
1336
|
+
asset.needsResolve = false;
|
|
1337
|
+
asset.assumedUploaded = true;
|
|
1338
|
+
logInfo(`${pc.green('✔')} Locally missing ${UPLOAD_ASSET_LABEL[asset.kind].toLowerCase()}, reusing the previously uploaded version: ${displayAssetPath(asset.path)}`);
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
unresolved.push(asset);
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
return unresolved;
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Builds the failure message shown when locally missing assets have no
|
|
1348
|
+
* previously uploaded version to reuse.
|
|
1349
|
+
*/
|
|
1350
|
+
export function formatUnresolvedAssetMessage(videoName, unresolved) {
|
|
1351
|
+
const list = unresolved
|
|
1352
|
+
.map((asset) => ` - ${UPLOAD_ASSET_LABEL[asset.kind]}: ${asset.path}`)
|
|
1353
|
+
.join('\n');
|
|
1354
|
+
return [
|
|
1355
|
+
`Some asset files are missing locally and no previously uploaded version was found for "${videoName}":`,
|
|
1356
|
+
list,
|
|
1357
|
+
'Record once with these files present so they are uploaded, or commit them so they are available here.',
|
|
1358
|
+
].join('\n');
|
|
1359
|
+
}
|
|
1044
1360
|
async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted, progressReporter) {
|
|
1045
1361
|
const logInfo = (message) => {
|
|
1046
1362
|
if (progressReporter) {
|
|
@@ -1052,63 +1368,75 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1052
1368
|
};
|
|
1053
1369
|
for (const asset of assets) {
|
|
1054
1370
|
throwIfAborted();
|
|
1371
|
+
const label = UPLOAD_ASSET_LABEL[asset.kind];
|
|
1055
1372
|
try {
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1373
|
+
if (!asset.alwaysUpload) {
|
|
1374
|
+
const checkRes = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/${recordingId}/asset/check`, {
|
|
1375
|
+
method: 'POST',
|
|
1376
|
+
headers: {
|
|
1377
|
+
'Content-Type': 'application/json',
|
|
1378
|
+
'X-ScreenCI-Secret': secret,
|
|
1379
|
+
},
|
|
1380
|
+
body: JSON.stringify({
|
|
1381
|
+
fileHash: asset.fileHash,
|
|
1382
|
+
contentType: asset.contentType,
|
|
1383
|
+
size: asset.size,
|
|
1384
|
+
path: asset.path,
|
|
1385
|
+
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
1386
|
+
}),
|
|
1387
|
+
signal,
|
|
1388
|
+
}), signal);
|
|
1389
|
+
if (!checkRes.ok) {
|
|
1390
|
+
const text = await checkRes.text();
|
|
1391
|
+
throw new UploadAssetError(`Failed to check asset ${displayAssetPath(asset.path)}: ${checkRes.status} ${extractBackendError(text)}${hint401(checkRes.status, secret)}`);
|
|
1392
|
+
}
|
|
1393
|
+
const checkBody = (await checkRes.json());
|
|
1394
|
+
if (checkBody.exists) {
|
|
1395
|
+
logInfo(asset.assumedUploaded
|
|
1396
|
+
? `${pc.green('✔')} Locally missing ${label.toLowerCase()}, already uploaded: ${displayAssetPath(asset.path)}`
|
|
1397
|
+
: `${pc.green('✔')} ${label} already exists: ${displayAssetPath(asset.path)}`);
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1079
1400
|
}
|
|
1080
1401
|
if (!asset.fileBuffer || !asset.contentType) {
|
|
1081
|
-
|
|
1402
|
+
// A locally missing asset matched a previous upload by path/name, but its
|
|
1403
|
+
// bytes are not in this environment's storage. There is nothing to push.
|
|
1404
|
+
if (asset.assumedUploaded) {
|
|
1405
|
+
throw new UploadAssetError(`${label} is missing locally and its previously uploaded bytes are no longer stored: ${displayAssetPath(asset.path)}. Record once with the file present so it is uploaded again, or commit the file.`);
|
|
1406
|
+
}
|
|
1407
|
+
throw new UploadAssetError(`Asset bytes not available for upload and backend does not have it yet: ${displayAssetPath(asset.path)}`);
|
|
1082
1408
|
}
|
|
1083
1409
|
const fileBuffer = asset.fileBuffer;
|
|
1410
|
+
const contentType = asset.contentType;
|
|
1084
1411
|
throwIfAborted();
|
|
1085
|
-
const res = await withUploadRetry(() =>
|
|
1412
|
+
const res = await withUploadRetry(() =>
|
|
1413
|
+
// Send the raw bytes, not base64-in-JSON: base64 builds a string that
|
|
1414
|
+
// exceeds Node's max string length (~536MB) for large assets. Metadata
|
|
1415
|
+
// travels in headers (URI-encoded so non-ASCII paths stay header-safe).
|
|
1416
|
+
fetch(`${apiUrl}/cli/upload/${recordingId}/asset/stream`, {
|
|
1086
1417
|
method: 'PUT',
|
|
1087
1418
|
headers: {
|
|
1088
|
-
'Content-Type':
|
|
1419
|
+
'Content-Type': contentType,
|
|
1420
|
+
'Content-Length': String(fileBuffer.byteLength),
|
|
1089
1421
|
'X-ScreenCI-Secret': secret,
|
|
1422
|
+
'X-ScreenCI-File-Hash': asset.fileHash,
|
|
1423
|
+
'X-ScreenCI-Asset-Size': String(asset.size),
|
|
1424
|
+
'X-ScreenCI-Asset-Path': encodeURIComponent(asset.path),
|
|
1090
1425
|
},
|
|
1091
|
-
body:
|
|
1092
|
-
fileHash: asset.fileHash,
|
|
1093
|
-
fileBase64: fileBuffer.toString('base64'),
|
|
1094
|
-
contentType: asset.contentType,
|
|
1095
|
-
size: asset.size,
|
|
1096
|
-
path: asset.path,
|
|
1097
|
-
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
1098
|
-
}),
|
|
1426
|
+
body: fileBuffer,
|
|
1099
1427
|
signal,
|
|
1100
1428
|
}), signal);
|
|
1101
1429
|
if (!res.ok) {
|
|
1102
1430
|
const text = await res.text();
|
|
1103
1431
|
if (res.status === 409 && text.includes('already exists')) {
|
|
1104
|
-
logInfo(`${pc.green('✔')}
|
|
1432
|
+
logInfo(`${pc.green('✔')} ${label} already exists: ${displayAssetPath(asset.path)}`);
|
|
1105
1433
|
}
|
|
1106
1434
|
else {
|
|
1107
|
-
throw new UploadAssetError(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
1435
|
+
throw new UploadAssetError(`Failed to upload asset ${displayAssetPath(asset.path)}: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1108
1436
|
}
|
|
1109
1437
|
}
|
|
1110
1438
|
else {
|
|
1111
|
-
logInfo(
|
|
1439
|
+
logInfo(`${pc.green('✔')} ${label} uploaded: ${displayAssetPath(asset.path)}`);
|
|
1112
1440
|
}
|
|
1113
1441
|
}
|
|
1114
1442
|
catch (err) {
|
|
@@ -1118,7 +1446,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1118
1446
|
if (isUploadAssetError(err)) {
|
|
1119
1447
|
throw err;
|
|
1120
1448
|
}
|
|
1121
|
-
throw new UploadAssetError(`Network error uploading asset ${asset.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1449
|
+
throw new UploadAssetError(`Network error uploading asset ${displayAssetPath(asset.path)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1122
1450
|
}
|
|
1123
1451
|
}
|
|
1124
1452
|
}
|
|
@@ -1138,6 +1466,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1138
1466
|
failedVideoNames: [],
|
|
1139
1467
|
failedVideoMessages: [],
|
|
1140
1468
|
studioNotices: [],
|
|
1469
|
+
elevenLabsKeyMissingVideos: [],
|
|
1470
|
+
notices: [],
|
|
1141
1471
|
plan: null,
|
|
1142
1472
|
};
|
|
1143
1473
|
}
|
|
@@ -1145,7 +1475,6 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1145
1475
|
entries = entries.filter((e) => e === specificEntry);
|
|
1146
1476
|
}
|
|
1147
1477
|
let firstProjectId = null;
|
|
1148
|
-
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
|
|
1149
1478
|
try {
|
|
1150
1479
|
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
1151
1480
|
uploadAbort.throwIfAborted();
|
|
@@ -1159,11 +1488,17 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1159
1488
|
failedVideoNames: [],
|
|
1160
1489
|
failedVideoMessages: [],
|
|
1161
1490
|
studioNotices: [],
|
|
1491
|
+
elevenLabsKeyMissingVideos: [],
|
|
1492
|
+
notices: [],
|
|
1162
1493
|
plan: null,
|
|
1163
1494
|
};
|
|
1164
1495
|
}
|
|
1165
1496
|
const progressReporter = createUploadProgressReporter(candidates.map((candidate) => candidate.videoName), verbose);
|
|
1166
|
-
|
|
1497
|
+
// Screenshots from this run render together on one machine; the backend
|
|
1498
|
+
// waits for all of them to land before dispatching the batch, so it needs
|
|
1499
|
+
// to know how many to expect.
|
|
1500
|
+
const screenshotCount = candidates.filter((candidate) => candidate.data.output === 'screenshot').length;
|
|
1501
|
+
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, index, recordId, screenshotCount)));
|
|
1167
1502
|
firstProjectId =
|
|
1168
1503
|
results.find((result) => result.projectId !== null)?.projectId ?? null;
|
|
1169
1504
|
const resolvedPlan = results.find((result) => result.plan !== undefined)?.plan ?? null;
|
|
@@ -1183,6 +1518,10 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1183
1518
|
},
|
|
1184
1519
|
]
|
|
1185
1520
|
: []);
|
|
1521
|
+
const elevenLabsKeyMissingVideos = results.flatMap((result) => !result.hadFailure && result.elevenLabsKeyMissing === true
|
|
1522
|
+
? [result.videoName]
|
|
1523
|
+
: []);
|
|
1524
|
+
const notices = results.flatMap((result) => !result.hadFailure && result.notices !== undefined ? result.notices : []);
|
|
1186
1525
|
return {
|
|
1187
1526
|
projectId: firstProjectId,
|
|
1188
1527
|
recordId,
|
|
@@ -1190,6 +1529,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1190
1529
|
failedVideoNames,
|
|
1191
1530
|
failedVideoMessages,
|
|
1192
1531
|
studioNotices,
|
|
1532
|
+
elevenLabsKeyMissingVideos,
|
|
1533
|
+
notices,
|
|
1193
1534
|
plan: resolvedPlan,
|
|
1194
1535
|
};
|
|
1195
1536
|
}
|
|
@@ -1310,19 +1651,29 @@ function isMissingFileError(err) {
|
|
|
1310
1651
|
}
|
|
1311
1652
|
async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
|
|
1312
1653
|
try {
|
|
1313
|
-
const
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
: resolve(dirname(resolvedConfigPath), '.env'), warnOnFailure);
|
|
1654
|
+
const envFilePath = (await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
|
|
1655
|
+
resolve(dirname(resolvedConfigPath), '.env');
|
|
1656
|
+
loadEnvFile(envFilePath, warnOnFailure);
|
|
1317
1657
|
}
|
|
1318
1658
|
catch {
|
|
1319
1659
|
// Config import may require Playwright context or dynamic values. Continue with
|
|
1320
1660
|
// the existing process env; Playwright will still load the config normally.
|
|
1321
1661
|
}
|
|
1322
1662
|
}
|
|
1323
|
-
|
|
1663
|
+
// Resolve `envFile` the way Playwright resolves a config value: by evaluating
|
|
1664
|
+
// the config module, not by scraping its source text. A dynamic
|
|
1665
|
+
// `envFile: isLocal ? '.env.local' : '.env'` only has a real value once the
|
|
1666
|
+
// module runs, so evaluating it lets local and prod setups pick the right file
|
|
1667
|
+
// (scraping the source can only see a plain string literal, and silently falls
|
|
1668
|
+
// back to `.env`). The environment the expression depends on (e.g.
|
|
1669
|
+
// SCREENCI_ENVIRONMENT, set by the `screenci:*:local` scripts) is already in
|
|
1670
|
+
// place before this runs, so the CLI resolves the same file Playwright will
|
|
1671
|
+
// when it later evaluates the same config. `loadRecordConfigWithoutPlaywright
|
|
1672
|
+
// Collision` only falls back to static source parsing if the module cannot be
|
|
1673
|
+
// imported.
|
|
1674
|
+
export async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
|
|
1324
1675
|
try {
|
|
1325
|
-
const screenciConfig = await
|
|
1676
|
+
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
1326
1677
|
if (!screenciConfig.envFile)
|
|
1327
1678
|
return undefined;
|
|
1328
1679
|
return resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
|
|
@@ -1459,7 +1810,7 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
|
1459
1810
|
});
|
|
1460
1811
|
if (!res.ok) {
|
|
1461
1812
|
const text = await res.text();
|
|
1462
|
-
throw new Error(`Failed to ${isPublic ? 'make public' : 'make private'}: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
1813
|
+
throw new Error(`Failed to ${isPublic ? 'make public' : 'make private'}: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1463
1814
|
}
|
|
1464
1815
|
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
1465
1816
|
}
|
|
@@ -1501,7 +1852,7 @@ async function triggerRemoteRun(configPath, grep, languages) {
|
|
|
1501
1852
|
});
|
|
1502
1853
|
if (!res.ok) {
|
|
1503
1854
|
const text = await res.text();
|
|
1504
|
-
throw new Error(`Failed to trigger remote run: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
1855
|
+
throw new Error(`Failed to trigger remote run: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1505
1856
|
}
|
|
1506
1857
|
const filters = [
|
|
1507
1858
|
...(grep ? [`filter: ${grep}`] : []),
|
|
@@ -1562,7 +1913,7 @@ async function printInfo(configPath) {
|
|
|
1562
1913
|
});
|
|
1563
1914
|
if (!res.ok) {
|
|
1564
1915
|
const text = await res.text();
|
|
1565
|
-
throw new Error(`Failed to fetch info: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
1916
|
+
throw new Error(`Failed to fetch info: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1566
1917
|
}
|
|
1567
1918
|
const data = await res.json();
|
|
1568
1919
|
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
@@ -1757,10 +2108,220 @@ export async function ensureScreenciSecret(resolvedConfigPath, opts = {}) {
|
|
|
1757
2108
|
return undefined;
|
|
1758
2109
|
}
|
|
1759
2110
|
}
|
|
2111
|
+
/**
|
|
2112
|
+
* `screenci login`: get a sign-in link in front of a human as early as
|
|
2113
|
+
* possible. It reuses the link `init` (or a prior run) persisted, or creates a
|
|
2114
|
+
* fresh one, tries to open it in the browser best-effort, and always prints it
|
|
2115
|
+
* as a fallback. It does NOT wait for sign-in to finish: the agent can run this,
|
|
2116
|
+
* keep authoring and `test`ing, then `record` (which blocks for sign-in) once
|
|
2117
|
+
* the script is green. If sign-in already completed, it saves the secret and
|
|
2118
|
+
* exits.
|
|
2119
|
+
*/
|
|
2120
|
+
export async function runLogin(configPath) {
|
|
2121
|
+
const { resolvedConfigPath } = await loadScreenCIConfigAndEnv(configPath);
|
|
2122
|
+
if (process.env.SCREENCI_SECRET) {
|
|
2123
|
+
logger.info(`Already signed in (SCREENCI_SECRET is set). Run ${pc.cyan(getSuggestedScreenciCommand('record'))} to record.`);
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
const environment = getScreenCIEnvironment();
|
|
2127
|
+
const apiUrl = getCliLinkSessionApiUrl();
|
|
2128
|
+
const appUrl = getDevFrontendUrl();
|
|
2129
|
+
const envFilePath = await resolveProjectEnvFilePath(resolvedConfigPath);
|
|
2130
|
+
const projectDir = dirname(resolvedConfigPath);
|
|
2131
|
+
const specPath = getLinkSessionFilePath(projectDir);
|
|
2132
|
+
const linkSessionContext = { environment, envFilePath, resolvedConfigPath };
|
|
2133
|
+
const createSpec = async () => {
|
|
2134
|
+
const created = await createLinkSessionSpec({
|
|
2135
|
+
apiUrl,
|
|
2136
|
+
appUrl,
|
|
2137
|
+
...linkSessionContext,
|
|
2138
|
+
});
|
|
2139
|
+
await writePersistedLinkSessionSpec(specPath, created);
|
|
2140
|
+
return created;
|
|
2141
|
+
};
|
|
2142
|
+
try {
|
|
2143
|
+
// Reuse a still-valid persisted session so login and a later record share
|
|
2144
|
+
// the same link; otherwise create and persist a fresh one.
|
|
2145
|
+
const stored = await readPersistedLinkSessionSpec(specPath);
|
|
2146
|
+
let spec = stored && isStoredLinkSessionReusable(stored, linkSessionContext)
|
|
2147
|
+
? stored
|
|
2148
|
+
: await createSpec();
|
|
2149
|
+
// If sign-in already completed (e.g. between runs) save the secret and stop:
|
|
2150
|
+
// there is nothing left to open. Recreate a stale session before opening so
|
|
2151
|
+
// the link we hand off is valid.
|
|
2152
|
+
const result = await pollLinkSessionOnce(spec);
|
|
2153
|
+
if (result.status === 'completed' && result.secret) {
|
|
2154
|
+
process.env.SCREENCI_SECRET = result.secret;
|
|
2155
|
+
await persistScreenCISecret(envFilePath, result.secret);
|
|
2156
|
+
deletePersistedLinkSessionSpec(specPath);
|
|
2157
|
+
logger.info(`Already signed in. Saved SCREENCI_SECRET to ${envFilePath}.`);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
if (result.status === 'expired' ||
|
|
2161
|
+
result.status === 'consumed' ||
|
|
2162
|
+
result.status === 'invalid') {
|
|
2163
|
+
deletePersistedLinkSessionSpec(specPath);
|
|
2164
|
+
spec = await createSpec();
|
|
2165
|
+
}
|
|
2166
|
+
const opened = await openUrlInBrowser(spec.appUrl);
|
|
2167
|
+
if (opened.opened) {
|
|
2168
|
+
logger.info(`Opening the sign-in link in your browser:\n${pc.cyan(spec.appUrl)}`);
|
|
2169
|
+
}
|
|
2170
|
+
else {
|
|
2171
|
+
logger.info(`Could not open a browser automatically (${opened.reason}). Open this link to sign in:\n${pc.cyan(spec.appUrl)}`);
|
|
2172
|
+
}
|
|
2173
|
+
logger.info(`The link stays valid for 24 hours. You can keep authoring and run ${pc.cyan(getSuggestedScreenciCommand('test'))} while you sign in. Then run ${pc.cyan(getSuggestedScreenciCommand('record'))}, which waits for sign-in and records once you finish.`);
|
|
2174
|
+
}
|
|
2175
|
+
catch (err) {
|
|
2176
|
+
// Login is a best-effort convenience: never fail the run on a network or
|
|
2177
|
+
// browser hiccup. record can still create and wait on the link later.
|
|
2178
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2179
|
+
logger.warn(`Could not start sign-in: ${msg}`);
|
|
2180
|
+
logger.info(`You can sign in later: ${pc.cyan(getSuggestedScreenciCommand('record'))} prints a link and waits for sign-in. In CI, set SCREENCI_SECRET (from ${getScreenCISecretsUrl()}).`);
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
// Uploads the recordings already written under `.screenci` for the resolved
|
|
2184
|
+
// config. Shared by `record` (after a Playwright run) and `retry` (which
|
|
2185
|
+
// re-sends the existing recordings without re-running Playwright). A non-null
|
|
2186
|
+
// `playwrightFailure` means the preceding record run had failures, which tunes
|
|
2187
|
+
// the messaging and the upload policy; `retry` always passes null.
|
|
2188
|
+
async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verbose) {
|
|
2189
|
+
// After recording, upload results to API if configured. `run` already
|
|
2190
|
+
// resolved the config (or exited), so this best-effort lookup only acts
|
|
2191
|
+
// when a flat config is present in/under the current directory.
|
|
2192
|
+
const resolution = findScreenCIConfig(configPath);
|
|
2193
|
+
if (resolution.kind !== 'found')
|
|
2194
|
+
return;
|
|
2195
|
+
const resolvedConfigPath = resolution.path;
|
|
2196
|
+
try {
|
|
2197
|
+
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
2198
|
+
loadEnvFile(screenciConfig.envFile
|
|
2199
|
+
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
|
|
2200
|
+
: resolve(dirname(resolvedConfigPath), '.env'), true);
|
|
2201
|
+
const apiUrl = getDevBackendUrl();
|
|
2202
|
+
const appUrl = getDevFrontendUrl();
|
|
2203
|
+
const secret = process.env.SCREENCI_SECRET;
|
|
2204
|
+
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig);
|
|
2205
|
+
const configDir = dirname(resolvedConfigPath);
|
|
2206
|
+
const screenciDir = resolve(configDir, '.screenci');
|
|
2207
|
+
const completedRecordingCount = await countCompletedRecordings(screenciDir);
|
|
2208
|
+
if (playwrightFailure !== null && completedRecordingCount === 0) {
|
|
2209
|
+
logger.info('All recordings failed.');
|
|
2210
|
+
}
|
|
2211
|
+
else if (!secret) {
|
|
2212
|
+
logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
|
|
2213
|
+
}
|
|
2214
|
+
else if (playwrightFailure !== null &&
|
|
2215
|
+
uploadPolicy === 'all-or-nothing') {
|
|
2216
|
+
logger.info('Some recordings failed, skipping upload because record.upload is "all-or-nothing".');
|
|
2217
|
+
}
|
|
2218
|
+
else {
|
|
2219
|
+
if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
|
|
2220
|
+
logger.warn('Some recordings failed, uploading successful videos only.');
|
|
2221
|
+
}
|
|
2222
|
+
let uploadResult = {
|
|
2223
|
+
projectId: null,
|
|
2224
|
+
recordId: null,
|
|
2225
|
+
hadFailures: false,
|
|
2226
|
+
failedVideoNames: [],
|
|
2227
|
+
failedVideoMessages: [],
|
|
2228
|
+
studioNotices: [],
|
|
2229
|
+
elevenLabsKeyMissingVideos: [],
|
|
2230
|
+
notices: [],
|
|
2231
|
+
plan: null,
|
|
2232
|
+
};
|
|
2233
|
+
try {
|
|
2234
|
+
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret, undefined, verbose);
|
|
2235
|
+
}
|
|
2236
|
+
catch (err) {
|
|
2237
|
+
if (isUploadCancelledError(err)) {
|
|
2238
|
+
process.exit(130);
|
|
2239
|
+
}
|
|
2240
|
+
throw err;
|
|
2241
|
+
}
|
|
2242
|
+
const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, elevenLabsKeyMissingVideos, notices, plan, } = uploadResult;
|
|
2243
|
+
// Remember this run so `screenci info` can report exactly it.
|
|
2244
|
+
if (recordId !== null) {
|
|
2245
|
+
await saveLastRecordId(screenciDir, recordId);
|
|
2246
|
+
}
|
|
2247
|
+
// Emit upload-failure warnings (stderr) before the results block.
|
|
2248
|
+
// logger.info writes to stdout, logger.warn to stderr; in non-TTY CI
|
|
2249
|
+
// logs stdout is block-buffered while stderr flushes immediately, so
|
|
2250
|
+
// warnings printed after the "Results available at:" line would split
|
|
2251
|
+
// it from its URL. Reporting failures first keeps the URL directly
|
|
2252
|
+
// under its message.
|
|
2253
|
+
if (hadFailures) {
|
|
2254
|
+
for (const failedVideo of failedVideoMessages) {
|
|
2255
|
+
logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
|
|
2256
|
+
}
|
|
2257
|
+
logger.warn(`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`);
|
|
2258
|
+
}
|
|
2259
|
+
if (recordId !== null && projectId !== null) {
|
|
2260
|
+
const recordUrl = `${appUrl}/record/${recordId}`;
|
|
2261
|
+
await writeGitHubProjectOutput(recordUrl);
|
|
2262
|
+
logger.info('');
|
|
2263
|
+
logger.info(playwrightFailure !== null
|
|
2264
|
+
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
2265
|
+
: 'Recording finished, rendering in progress. Results available at:');
|
|
2266
|
+
logger.info(pc.cyan(recordUrl));
|
|
2267
|
+
}
|
|
2268
|
+
else if (projectId !== null) {
|
|
2269
|
+
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
2270
|
+
await writeGitHubProjectOutput(projectUrl);
|
|
2271
|
+
logger.info('');
|
|
2272
|
+
logger.info(playwrightFailure !== null
|
|
2273
|
+
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
2274
|
+
: 'Recording finished, rendering in progress. Results available at:');
|
|
2275
|
+
logger.info(pc.cyan(projectUrl));
|
|
2276
|
+
}
|
|
2277
|
+
if (notices.length > 0) {
|
|
2278
|
+
logger.info('');
|
|
2279
|
+
for (const notice of notices) {
|
|
2280
|
+
logger.notice(notice);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
if (projectId !== null && plan !== 'business') {
|
|
2284
|
+
logger.info('');
|
|
2285
|
+
logger.info('Upgrade for more renders, more active videos, and expressive narration:');
|
|
2286
|
+
logger.info(pc.cyan(`${appUrl}/select-plan`));
|
|
2287
|
+
}
|
|
2288
|
+
for (const notice of studioNotices) {
|
|
2289
|
+
if ('held' in notice.studio) {
|
|
2290
|
+
logger.info('');
|
|
2291
|
+
logger.info(`Rendering for "${notice.videoName}" is on hold. Configure it in Studio:`);
|
|
2292
|
+
if (projectId !== null && notice.videoId !== null) {
|
|
2293
|
+
logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
else if (notice.studio.applied) {
|
|
2297
|
+
logger.info('');
|
|
2298
|
+
logger.info(`Studio configuration applied for "${notice.videoName}".`);
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
if (elevenLabsKeyMissingVideos.length > 0) {
|
|
2302
|
+
const names = elevenLabsKeyMissingVideos
|
|
2303
|
+
.map((name) => `"${name}"`)
|
|
2304
|
+
.join(', ');
|
|
2305
|
+
logger.info('');
|
|
2306
|
+
logger.error(`${names} ${elevenLabsKeyMissingVideos.length === 1 ? 'uses' : 'use'} an ElevenLabs or custom voice, but your organization has no ElevenLabs API key, so ${elevenLabsKeyMissingVideos.length === 1 ? 'its render' : 'those renders'} will fail. Add your key on the Secrets page:`);
|
|
2307
|
+
logger.info(pc.cyan(getScreenCISecretsUrl()));
|
|
2308
|
+
}
|
|
2309
|
+
if (hadFailures && playwrightFailure === null) {
|
|
2310
|
+
throw new PartialUploadError();
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
catch (err) {
|
|
2315
|
+
if (isPartialUploadError(err)) {
|
|
2316
|
+
throw err;
|
|
2317
|
+
}
|
|
2318
|
+
logger.warn('Failed to load config for upload:', err);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
1760
2321
|
export async function main() {
|
|
1761
2322
|
if (process.argv.length <= 2) {
|
|
1762
2323
|
logger.error('Error: No command provided');
|
|
1763
|
-
logger.error('Available commands: record, test, info, make-public, make-private, init');
|
|
2324
|
+
logger.error('Available commands: record, test, login, info, make-public, make-private, init');
|
|
1764
2325
|
process.exit(1);
|
|
1765
2326
|
}
|
|
1766
2327
|
const program = new Command();
|
|
@@ -1788,130 +2349,32 @@ export async function main() {
|
|
|
1788
2349
|
return;
|
|
1789
2350
|
}
|
|
1790
2351
|
let playwrightFailure = null;
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
if (error.message.startsWith('Playwright exited with code ')) {
|
|
1798
|
-
playwrightFailure = new RecordFailureHintError(error);
|
|
1799
|
-
}
|
|
1800
|
-
else {
|
|
1801
|
-
throw new RecordFailureHintError(error);
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1805
|
-
return;
|
|
1806
|
-
// After recording, upload results to API if configured. `run` already
|
|
1807
|
-
// resolved the config (or exited), so this best-effort lookup only acts
|
|
1808
|
-
// when a flat config is present in/under the current directory.
|
|
1809
|
-
const resolution = findScreenCIConfig(parsed.configPath);
|
|
1810
|
-
if (resolution.kind === 'found') {
|
|
1811
|
-
const resolvedConfigPath = resolution.path;
|
|
2352
|
+
// UPLOAD_EXISTING re-sends the recordings already on disk under `.screenci`
|
|
2353
|
+
// without re-running Playwright (resend the last local run when only the
|
|
2354
|
+
// upload failed). We skip the recording run and fall straight through to
|
|
2355
|
+
// the upload below, treating the on-disk recordings as the complete set.
|
|
2356
|
+
const uploadExisting = isUploadExistingEnabled();
|
|
2357
|
+
if (!uploadExisting) {
|
|
1812
2358
|
try {
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig);
|
|
1821
|
-
const configDir = dirname(resolvedConfigPath);
|
|
1822
|
-
const screenciDir = resolve(configDir, '.screenci');
|
|
1823
|
-
const completedRecordingCount = await countCompletedRecordings(screenciDir);
|
|
1824
|
-
if (playwrightFailure !== null && completedRecordingCount === 0) {
|
|
1825
|
-
logger.info('All recordings failed.');
|
|
1826
|
-
}
|
|
1827
|
-
else if (!secret) {
|
|
1828
|
-
logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
|
|
1829
|
-
}
|
|
1830
|
-
else if (playwrightFailure !== null &&
|
|
1831
|
-
uploadPolicy === 'all-or-nothing') {
|
|
1832
|
-
logger.info('Some recordings failed, skipping upload because record.upload is "all-or-nothing".');
|
|
2359
|
+
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose, false, parsed.pollAuth, parsed.languages, parsed.noPollAuth);
|
|
2360
|
+
}
|
|
2361
|
+
catch (error) {
|
|
2362
|
+
if (!(error instanceof Error))
|
|
2363
|
+
throw error;
|
|
2364
|
+
if (error.message.startsWith('Playwright exited with code ')) {
|
|
2365
|
+
playwrightFailure = new RecordFailureHintError(error);
|
|
1833
2366
|
}
|
|
1834
2367
|
else {
|
|
1835
|
-
|
|
1836
|
-
logger.warn('Some recordings failed, uploading successful videos only.');
|
|
1837
|
-
}
|
|
1838
|
-
let uploadResult = {
|
|
1839
|
-
projectId: null,
|
|
1840
|
-
recordId: null,
|
|
1841
|
-
hadFailures: false,
|
|
1842
|
-
failedVideoNames: [],
|
|
1843
|
-
failedVideoMessages: [],
|
|
1844
|
-
studioNotices: [],
|
|
1845
|
-
plan: null,
|
|
1846
|
-
};
|
|
1847
|
-
try {
|
|
1848
|
-
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
1849
|
-
}
|
|
1850
|
-
catch (err) {
|
|
1851
|
-
if (isUploadCancelledError(err)) {
|
|
1852
|
-
process.exit(130);
|
|
1853
|
-
}
|
|
1854
|
-
throw err;
|
|
1855
|
-
}
|
|
1856
|
-
const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, plan, } = uploadResult;
|
|
1857
|
-
// Remember this run so `screenci info` can report exactly it.
|
|
1858
|
-
if (recordId !== null) {
|
|
1859
|
-
await saveLastRecordId(screenciDir, recordId);
|
|
1860
|
-
}
|
|
1861
|
-
if (recordId !== null && projectId !== null) {
|
|
1862
|
-
const recordUrl = `${appUrl}/record/${recordId}`;
|
|
1863
|
-
await writeGitHubProjectOutput(recordUrl);
|
|
1864
|
-
logger.info('');
|
|
1865
|
-
logger.info(playwrightFailure !== null
|
|
1866
|
-
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
1867
|
-
: 'Recording finished, rendering in progress. Results available at:');
|
|
1868
|
-
logger.info(pc.cyan(recordUrl));
|
|
1869
|
-
}
|
|
1870
|
-
else if (projectId !== null) {
|
|
1871
|
-
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
1872
|
-
await writeGitHubProjectOutput(projectUrl);
|
|
1873
|
-
logger.info('');
|
|
1874
|
-
logger.info(playwrightFailure !== null
|
|
1875
|
-
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
1876
|
-
: 'Recording finished, rendering in progress. Results available at:');
|
|
1877
|
-
logger.info(pc.cyan(projectUrl));
|
|
1878
|
-
}
|
|
1879
|
-
if (projectId !== null && plan !== 'business') {
|
|
1880
|
-
logger.info('');
|
|
1881
|
-
logger.info('Upgrade for more renders, more active videos, and expressive narration:');
|
|
1882
|
-
logger.info(pc.cyan(`${appUrl}/select-plan`));
|
|
1883
|
-
}
|
|
1884
|
-
for (const notice of studioNotices) {
|
|
1885
|
-
if ('held' in notice.studio) {
|
|
1886
|
-
logger.info('');
|
|
1887
|
-
logger.info(`Rendering for "${notice.videoName}" is on hold — configure it in Studio:`);
|
|
1888
|
-
if (projectId !== null && notice.videoId !== null) {
|
|
1889
|
-
logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
else if (notice.studio.applied) {
|
|
1893
|
-
logger.info('');
|
|
1894
|
-
logger.info(`Studio configuration applied for "${notice.videoName}".`);
|
|
1895
|
-
}
|
|
1896
|
-
}
|
|
1897
|
-
if (hadFailures) {
|
|
1898
|
-
for (const failedVideo of failedVideoMessages) {
|
|
1899
|
-
logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
|
|
1900
|
-
}
|
|
1901
|
-
logger.warn(`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`);
|
|
1902
|
-
if (playwrightFailure === null) {
|
|
1903
|
-
throw new PartialUploadError();
|
|
1904
|
-
}
|
|
1905
|
-
}
|
|
2368
|
+
throw new RecordFailureHintError(error);
|
|
1906
2369
|
}
|
|
1907
2370
|
}
|
|
1908
|
-
catch (err) {
|
|
1909
|
-
if (isPartialUploadError(err)) {
|
|
1910
|
-
throw err;
|
|
1911
|
-
}
|
|
1912
|
-
logger.warn('Failed to load config for upload:', err);
|
|
1913
|
-
}
|
|
1914
2371
|
}
|
|
2372
|
+
else {
|
|
2373
|
+
logger.info('UPLOAD_EXISTING set: skipping Playwright recording and re-uploading existing .screenci recordings.');
|
|
2374
|
+
}
|
|
2375
|
+
if (process.env.SCREENCI_RECORDING === 'true')
|
|
2376
|
+
return;
|
|
2377
|
+
await uploadRecordedVideosForConfig(parsed.configPath, playwrightFailure, parsed.verbose);
|
|
1915
2378
|
if (playwrightFailure !== null) {
|
|
1916
2379
|
throw playwrightFailure;
|
|
1917
2380
|
}
|
|
@@ -1948,6 +2411,13 @@ export async function main() {
|
|
|
1948
2411
|
const recordCommand = getSuggestedScreenciCommand('record');
|
|
1949
2412
|
logger.info(`Tests passed. Run ${pc.cyan(recordCommand)} to render the videos.`);
|
|
1950
2413
|
});
|
|
2414
|
+
program
|
|
2415
|
+
.command('login')
|
|
2416
|
+
.description('Open the sign-in link in your browser (best-effort) so recording can finish later')
|
|
2417
|
+
.option('-c, --config <path>', 'path to screenci.config.ts')
|
|
2418
|
+
.action(async (options) => {
|
|
2419
|
+
await runLogin(options['config']);
|
|
2420
|
+
});
|
|
1951
2421
|
program
|
|
1952
2422
|
.command('info')
|
|
1953
2423
|
.description("Print the latest record run's video URLs and render status as JSON")
|
|
@@ -2132,7 +2602,7 @@ function validateArgs(args) {
|
|
|
2132
2602
|
}
|
|
2133
2603
|
/**
|
|
2134
2604
|
* Fetch the project's current Studio text-field overrides so they can be
|
|
2135
|
-
* injected as
|
|
2605
|
+
* injected as SCREENCI_VALUES_OVERRIDES before a recording. On-screen `values`
|
|
2136
2606
|
* fields render during the recording (they cannot be patched at render time),
|
|
2137
2607
|
* so any Studio edits must be present here; code-declared seeds are the
|
|
2138
2608
|
* fallback. Best-effort: any failure returns an empty env so the SDK uses the
|
|
@@ -2161,7 +2631,7 @@ async function fetchTextOverridesEnv(configPath, languages, verbose) {
|
|
|
2161
2631
|
Object.keys(overrides).length === 0) {
|
|
2162
2632
|
return {};
|
|
2163
2633
|
}
|
|
2164
|
-
return { [
|
|
2634
|
+
return { [SCREENCI_VALUES_OVERRIDES_ENV]: JSON.stringify(overrides) };
|
|
2165
2635
|
}
|
|
2166
2636
|
catch (error) {
|
|
2167
2637
|
if (verbose) {
|