screenci 0.0.76 → 0.0.79
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 +58 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +584 -200
- package/dist/cli.js.map +1 -1
- package/dist/docs/manifest.d.ts +253 -98
- package/dist/docs/manifest.d.ts.map +1 -1
- package/dist/docs/manifest.js +92 -32
- 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 +9 -6
- package/dist/docs/video-sources/installation.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 +1 -1
- package/dist/docs/video-sources/locator-highlight-animated.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/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 +8 -0
- 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 +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/src/asset.d.ts +154 -13
- package/dist/src/asset.d.ts.map +1 -1
- package/dist/src/asset.js +262 -32
- 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 +8 -5
- package/dist/src/audio.d.ts.map +1 -1
- package/dist/src/audio.js +56 -31
- package/dist/src/audio.js.map +1 -1
- package/dist/src/builder.d.ts +132 -32
- package/dist/src/builder.d.ts.map +1 -1
- package/dist/src/builder.js +66 -20
- package/dist/src/builder.js.map +1 -1
- package/dist/src/changeFocus.d.ts.map +1 -1
- package/dist/src/changeFocus.js +103 -34
- package/dist/src/changeFocus.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 +8 -4
- package/dist/src/declare.d.ts.map +1 -1
- package/dist/src/declare.js +28 -10
- package/dist/src/declare.js.map +1 -1
- package/dist/src/defaults.d.ts +23 -0
- package/dist/src/defaults.d.ts.map +1 -1
- package/dist/src/defaults.js +23 -0
- package/dist/src/defaults.js.map +1 -1
- package/dist/src/events.d.ts +289 -25
- package/dist/src/events.d.ts.map +1 -1
- package/dist/src/events.js +145 -13
- 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 +60 -28
- package/dist/src/init.js.map +1 -1
- package/dist/src/instrument.d.ts.map +1 -1
- package/dist/src/instrument.js +25 -7
- 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 +25 -17
- package/dist/src/localize.d.ts.map +1 -1
- package/dist/src/localize.js +18 -10
- package/dist/src/localize.js.map +1 -1
- package/dist/src/localizeRuntime.d.ts +18 -13
- package/dist/src/localizeRuntime.d.ts.map +1 -1
- package/dist/src/localizeRuntime.js +28 -18
- 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/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/recordingData.d.ts +7 -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 +34 -4
- package/dist/src/screenAudio.d.ts.map +1 -1
- package/dist/src/screenAudio.js +0 -0
- package/dist/src/screenAudio.js.map +1 -1
- package/dist/src/screenAudioSink.d.ts +12 -0
- package/dist/src/screenAudioSink.d.ts.map +1 -1
- package/dist/src/screenAudioSink.js +30 -0
- package/dist/src/screenAudioSink.js.map +1 -1
- package/dist/src/screenshot.d.ts +16 -14
- package/dist/src/screenshot.d.ts.map +1 -1
- package/dist/src/screenshot.js +11 -11
- 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/studio.d.ts +71 -3
- package/dist/src/studio.d.ts.map +1 -1
- package/dist/src/studio.js +19 -1
- 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 +85 -0
- 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 +37 -34
- package/dist/src/video.d.ts.map +1 -1
- package/dist/src/video.js +99 -48
- 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/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/templates/recordings/assets/logo.png +0 -0
package/dist/cli.js
CHANGED
|
@@ -10,11 +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';
|
|
17
18
|
import { openUrlInBrowser } from './src/openBrowser.js';
|
|
19
|
+
import { maybeExtractVoiceSampleAudio } from './src/voiceSampleAudio.js';
|
|
18
20
|
// Re-export the environment-aware URL helpers so existing importers (and tests)
|
|
19
21
|
// can keep importing them from the CLI entrypoint.
|
|
20
22
|
export { getCliLinkSessionApiUrl, getDevBackendUrl, getDevFrontendUrl };
|
|
@@ -222,6 +224,15 @@ function contentTypeForPath(filePath) {
|
|
|
222
224
|
};
|
|
223
225
|
return contentTypeMap[ext] ?? 'application/octet-stream';
|
|
224
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
|
+
};
|
|
225
236
|
export function formatStudioUrl(appUrl, projectId, videoId) {
|
|
226
237
|
// The video hub resolves `?studio` to the right language page and scrolls it
|
|
227
238
|
// to Studio, so we never need to guess the language in the printed link.
|
|
@@ -349,15 +360,19 @@ async function loadUploadCandidate(screenciDir, entry, verbose) {
|
|
|
349
360
|
}
|
|
350
361
|
const videoName = data.metadata?.videoName ?? entry;
|
|
351
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.
|
|
352
367
|
return {
|
|
353
368
|
entry,
|
|
354
369
|
videoName,
|
|
355
|
-
data
|
|
370
|
+
data,
|
|
356
371
|
preparedUploadAssets,
|
|
357
372
|
};
|
|
358
373
|
}
|
|
359
|
-
async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret,
|
|
360
|
-
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;
|
|
361
376
|
let projectId = null;
|
|
362
377
|
let videoId = null;
|
|
363
378
|
let plan = null;
|
|
@@ -366,9 +381,9 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
366
381
|
// A screenshot recording uploads its raw page capture (always a PNG) through
|
|
367
382
|
// the same recording endpoint a video uses; the renderer reads those bytes as
|
|
368
383
|
// the capture. Videos upload recording.mp4. Output kind defaults to 'video'.
|
|
369
|
-
const isScreenshot =
|
|
384
|
+
const isScreenshot = rawData.output === 'screenshot';
|
|
370
385
|
const recordingFileName = isScreenshot
|
|
371
|
-
? (
|
|
386
|
+
? (rawData.screenshot?.path ?? 'screenshot.png')
|
|
372
387
|
: 'recording.mp4';
|
|
373
388
|
const recordingContentType = isScreenshot ? 'image/png' : 'video/mp4';
|
|
374
389
|
const recordingPath = resolve(screenciDir, entry, recordingFileName);
|
|
@@ -383,15 +398,29 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
383
398
|
recordId,
|
|
384
399
|
};
|
|
385
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);
|
|
386
418
|
const recordingHash = await hashFile(recordingPath);
|
|
387
419
|
const startResponse = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/start`, {
|
|
388
420
|
method: 'POST',
|
|
389
421
|
headers: {
|
|
390
422
|
'Content-Type': 'application/json',
|
|
391
423
|
'X-ScreenCI-Secret': secret,
|
|
392
|
-
...(elevenLabsApiKey
|
|
393
|
-
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
394
|
-
: {}),
|
|
395
424
|
},
|
|
396
425
|
body: JSON.stringify({
|
|
397
426
|
projectName,
|
|
@@ -415,6 +444,20 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
415
444
|
if (!startResponse.ok) {
|
|
416
445
|
const text = await startResponse.text();
|
|
417
446
|
progressReporter.complete(progressIndex, 'failure');
|
|
447
|
+
// A missing ElevenLabs key fails the render server-side and is reported
|
|
448
|
+
// once in the final summary (see uploadRecordings) as a dedicated error
|
|
449
|
+
// with the Secrets link, so it carries the flag instead of a generic
|
|
450
|
+
// upload-failure message that would duplicate it.
|
|
451
|
+
if (responseFlagsElevenLabsKeyMissing(text)) {
|
|
452
|
+
return {
|
|
453
|
+
projectId: null,
|
|
454
|
+
videoId: null,
|
|
455
|
+
hadFailure: true,
|
|
456
|
+
elevenLabsKeyMissing: true,
|
|
457
|
+
videoName,
|
|
458
|
+
recordId,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
418
461
|
return {
|
|
419
462
|
projectId: null,
|
|
420
463
|
videoId: null,
|
|
@@ -430,6 +473,17 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
430
473
|
videoId = startBody.videoId ?? null;
|
|
431
474
|
plan = startBody.plan ?? null;
|
|
432
475
|
const studio = startBody.studio;
|
|
476
|
+
// Render-dependency validation failures are reported on the upload response
|
|
477
|
+
// so the author sees them at record time. These are enforced server-side:
|
|
478
|
+
// the render for this video fails with the dependency error until fixed.
|
|
479
|
+
if (startBody.dependencyErrors && startBody.dependencyErrors.length > 0) {
|
|
480
|
+
for (const depError of startBody.dependencyErrors) {
|
|
481
|
+
logger.error(`Render dependency error in "${videoName}": ${depError.detail}. This render will fail until it is fixed.`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// A missing ElevenLabs key is a hard failure returned as an error response
|
|
485
|
+
// (handled in the !startResponse.ok branch above), surfaced once in the
|
|
486
|
+
// final summary where it is not overwritten by the upload spinner.
|
|
433
487
|
if (verbose) {
|
|
434
488
|
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
435
489
|
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
@@ -453,9 +507,6 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
453
507
|
'Content-Type': recordingContentType,
|
|
454
508
|
'Content-Length': String(fileStat.size),
|
|
455
509
|
'X-ScreenCI-Secret': secret,
|
|
456
|
-
...(elevenLabsApiKey
|
|
457
|
-
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
458
|
-
: {}),
|
|
459
510
|
},
|
|
460
511
|
body: stream,
|
|
461
512
|
signal: uploadAbort.signal,
|
|
@@ -475,7 +526,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
475
526
|
videoId,
|
|
476
527
|
hadFailure: true,
|
|
477
528
|
videoName,
|
|
478
|
-
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${text}${hint401(recordingResponse.status, secret)}`,
|
|
529
|
+
failureMessage: `Failed to upload recording for "${videoName}": ${recordingResponse.status} ${extractBackendError(text)}${hint401(recordingResponse.status, secret)}`,
|
|
479
530
|
recordId,
|
|
480
531
|
...(plan !== null && { plan }),
|
|
481
532
|
};
|
|
@@ -490,6 +541,10 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
490
541
|
recordId,
|
|
491
542
|
...(studio !== undefined && { studio }),
|
|
492
543
|
...(plan !== null && { plan }),
|
|
544
|
+
...(Array.isArray(startBody.notices) &&
|
|
545
|
+
startBody.notices.length > 0 && {
|
|
546
|
+
notices: startBody.notices.filter((notice) => typeof notice === 'string'),
|
|
547
|
+
}),
|
|
493
548
|
};
|
|
494
549
|
}
|
|
495
550
|
catch (err) {
|
|
@@ -713,11 +768,17 @@ function forwardChildSignals(child, activityLabel, options = {}) {
|
|
|
713
768
|
getForwardedSignal: () => forwardedSignal,
|
|
714
769
|
};
|
|
715
770
|
}
|
|
716
|
-
function clearRecordingDirectories(dir) {
|
|
771
|
+
export function clearRecordingDirectories(dir) {
|
|
717
772
|
mkdirSync(dir, { recursive: true });
|
|
718
773
|
for (const entry of readdirSync(dir)) {
|
|
719
774
|
if (entry === SCREENCI_LINK_SESSION_FILE)
|
|
720
775
|
continue;
|
|
776
|
+
// Preserve the cross-run overlay cache: it lives as a sibling of the
|
|
777
|
+
// per-recording directories so unchanged overlays are served byte for byte
|
|
778
|
+
// from a previous run. Wiping it would re-render and re-encode every overlay
|
|
779
|
+
// each run, changing their content hashes and forcing a re-upload.
|
|
780
|
+
if (entry === OVERLAY_CACHE_DIR_NAME)
|
|
781
|
+
continue;
|
|
721
782
|
rmSync(resolve(dir, entry), { recursive: true, force: true });
|
|
722
783
|
}
|
|
723
784
|
}
|
|
@@ -826,32 +887,56 @@ async function prepareCustomVoiceAssets(data, configDir) {
|
|
|
826
887
|
const resolvedFile = await readRecordingFile(voicePath, configDir, sourceFilePath);
|
|
827
888
|
if (resolvedFile === null) {
|
|
828
889
|
const existingHash = refs.find((ref) => typeof ref.assetHash === 'string')?.assetHash;
|
|
829
|
-
if (
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
890
|
+
if (existingHash) {
|
|
891
|
+
// The recording already carries this voice's content hash, so reference
|
|
892
|
+
// it by that hash. The backend check confirms it is still stored.
|
|
893
|
+
for (const ref of refs) {
|
|
894
|
+
ref.assetHash = existingHash;
|
|
895
|
+
}
|
|
896
|
+
preparedAssets.push({
|
|
897
|
+
kind: 'voice',
|
|
898
|
+
fileHash: existingHash,
|
|
899
|
+
path: voicePath,
|
|
900
|
+
size: 0,
|
|
901
|
+
contentType: contentTypeForPath(voicePath),
|
|
902
|
+
assumedUploaded: true,
|
|
903
|
+
});
|
|
904
|
+
continue;
|
|
835
905
|
}
|
|
906
|
+
// No cached hash either. Recover the voice's identity from a previous
|
|
907
|
+
// upload of this video, matched by path. resolveMissingUploadAssets fills
|
|
908
|
+
// in the hash and writes it back onto the cue refs.
|
|
836
909
|
preparedAssets.push({
|
|
837
|
-
|
|
910
|
+
kind: 'voice',
|
|
911
|
+
fileHash: '',
|
|
838
912
|
path: voicePath,
|
|
839
913
|
size: 0,
|
|
840
914
|
contentType: contentTypeForPath(voicePath),
|
|
915
|
+
needsResolve: true,
|
|
841
916
|
});
|
|
842
917
|
continue;
|
|
843
918
|
}
|
|
844
919
|
const { buffer: fileBuffer, resolvedPath } = resolvedFile;
|
|
845
|
-
|
|
846
|
-
|
|
920
|
+
// Identify the voice by the ORIGINAL file, so the clone cache survives any
|
|
921
|
+
// re-encode of the uploaded bytes (see backend resolveCustomVoice).
|
|
922
|
+
const sampleHash = createHash('sha256').update(fileBuffer).digest('hex');
|
|
923
|
+
// A voice clone only needs audio, and the clone service caps samples at
|
|
924
|
+
// ~11 MB. For video containers (or any oversized file) strip to a small MP3
|
|
925
|
+
// before upload; otherwise upload the audio unchanged.
|
|
926
|
+
const extracted = await maybeExtractVoiceSampleAudio(resolvedPath, fileBuffer.byteLength);
|
|
927
|
+
const uploadBuffer = extracted?.buffer ?? fileBuffer;
|
|
928
|
+
const contentType = extracted?.contentType ?? contentTypeForPath(resolvedPath);
|
|
929
|
+
const assetHash = createHash('sha256').update(uploadBuffer).digest('hex');
|
|
847
930
|
for (const ref of refs) {
|
|
848
931
|
ref.assetHash = assetHash;
|
|
932
|
+
ref.sampleHash = sampleHash;
|
|
849
933
|
}
|
|
850
934
|
preparedAssets.push({
|
|
935
|
+
kind: 'voice',
|
|
851
936
|
fileHash: assetHash,
|
|
852
937
|
path: voicePath,
|
|
853
|
-
size:
|
|
854
|
-
fileBuffer,
|
|
938
|
+
size: uploadBuffer.byteLength,
|
|
939
|
+
fileBuffer: uploadBuffer,
|
|
855
940
|
contentType,
|
|
856
941
|
});
|
|
857
942
|
}
|
|
@@ -866,15 +951,29 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
866
951
|
// page and merged into the recording by the backend.
|
|
867
952
|
if ('studio' in event && event.studio === true)
|
|
868
953
|
continue;
|
|
954
|
+
// Render dependencies (selected(...)) have no local file: the backend
|
|
955
|
+
// resolves the target render's output and injects it at dispatch time.
|
|
956
|
+
if ('dependency' in event)
|
|
957
|
+
continue;
|
|
869
958
|
if (assets.has(`name:${event.name}`))
|
|
870
959
|
continue;
|
|
871
960
|
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
872
961
|
if (resolvedFile === null) {
|
|
873
|
-
|
|
962
|
+
// The local file is gone (e.g. gitignored media on CI). Reference it so
|
|
963
|
+
// its identity can be recovered from a previous upload of this video.
|
|
964
|
+
assets.set(`name:${event.name}`, {
|
|
965
|
+
kind: 'overlay',
|
|
966
|
+
fileHash: '',
|
|
967
|
+
path: event.path,
|
|
968
|
+
name: event.name,
|
|
969
|
+
size: 0,
|
|
970
|
+
needsResolve: true,
|
|
971
|
+
});
|
|
874
972
|
continue;
|
|
875
973
|
}
|
|
876
974
|
const { buffer: fileBuffer, resolvedPath } = resolvedFile;
|
|
877
975
|
assets.set(`name:${event.name}`, {
|
|
976
|
+
kind: 'overlay',
|
|
878
977
|
fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
|
|
879
978
|
path: event.path,
|
|
880
979
|
name: event.name,
|
|
@@ -888,15 +987,45 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
888
987
|
// Studio audio tracks have no local file.
|
|
889
988
|
if ('studio' in event && event.studio === true)
|
|
890
989
|
continue;
|
|
891
|
-
|
|
990
|
+
// Prefer the record-time content hash as the dedup key; a missing local
|
|
991
|
+
// file may have been emitted without one, so fall back to the path.
|
|
992
|
+
const dedupKey = event.fileHash
|
|
993
|
+
? `hash:${event.fileHash}`
|
|
994
|
+
: `path:${event.path}`;
|
|
995
|
+
if (assets.has(dedupKey))
|
|
892
996
|
continue;
|
|
893
997
|
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
894
998
|
if (resolvedFile === null) {
|
|
895
|
-
|
|
999
|
+
// The local file is gone. If the recording still carries its content
|
|
1000
|
+
// hash, reference it by that hash (the backend check confirms it is
|
|
1001
|
+
// stored). Otherwise recover its identity from a previous upload by path.
|
|
1002
|
+
// Captured screen audio (`__screen`) is per-recording and can never be
|
|
1003
|
+
// recovered from a prior upload, so it is simply skipped when missing.
|
|
1004
|
+
if (event.fileHash) {
|
|
1005
|
+
assets.set(dedupKey, {
|
|
1006
|
+
kind: 'audio',
|
|
1007
|
+
fileHash: event.fileHash,
|
|
1008
|
+
path: event.path,
|
|
1009
|
+
size: 0,
|
|
1010
|
+
assumedUploaded: true,
|
|
1011
|
+
...(event.name === '__screen' && { alwaysUpload: true }),
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
else if (event.name !== '__screen') {
|
|
1015
|
+
assets.set(dedupKey, {
|
|
1016
|
+
kind: 'audio',
|
|
1017
|
+
fileHash: '',
|
|
1018
|
+
path: event.path,
|
|
1019
|
+
size: 0,
|
|
1020
|
+
needsResolve: true,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
896
1023
|
continue;
|
|
897
1024
|
}
|
|
898
|
-
assets.set(
|
|
899
|
-
|
|
1025
|
+
assets.set(dedupKey, {
|
|
1026
|
+
kind: 'audio',
|
|
1027
|
+
fileHash: event.fileHash ??
|
|
1028
|
+
createHash('sha256').update(resolvedFile.buffer).digest('hex'),
|
|
900
1029
|
path: event.path,
|
|
901
1030
|
size: resolvedFile.buffer.byteLength,
|
|
902
1031
|
fileBuffer: resolvedFile.buffer,
|
|
@@ -913,38 +1042,79 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
913
1042
|
? await readRecordingFile(event.assetPath, configDir, sourceFilePath)
|
|
914
1043
|
: null;
|
|
915
1044
|
assets.set(`hash:${event.assetHash}`, {
|
|
1045
|
+
kind: 'clip',
|
|
916
1046
|
fileHash: event.assetHash,
|
|
917
1047
|
path: event.assetPath ?? event.assetHash,
|
|
918
1048
|
size: resolvedFile?.buffer.byteLength ?? 0,
|
|
919
|
-
...(resolvedFile !== null
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1049
|
+
...(resolvedFile !== null
|
|
1050
|
+
? {
|
|
1051
|
+
fileBuffer: resolvedFile.buffer,
|
|
1052
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
1053
|
+
}
|
|
1054
|
+
: { assumedUploaded: true }),
|
|
923
1055
|
});
|
|
924
1056
|
}
|
|
1057
|
+
else if (typeof event.assetHash !== 'string' &&
|
|
1058
|
+
typeof event.assetPath === 'string' &&
|
|
1059
|
+
!assets.has(`path:${event.assetPath}`)) {
|
|
1060
|
+
// The media file was gone at record time, so it carries no hash. Recover
|
|
1061
|
+
// its identity from a previous upload of this video, matched by path.
|
|
1062
|
+
const resolvedFile = await readRecordingFile(event.assetPath, configDir, sourceFilePath);
|
|
1063
|
+
if (resolvedFile === null) {
|
|
1064
|
+
assets.set(`path:${event.assetPath}`, {
|
|
1065
|
+
kind: 'clip',
|
|
1066
|
+
fileHash: '',
|
|
1067
|
+
path: event.assetPath,
|
|
1068
|
+
size: 0,
|
|
1069
|
+
needsResolve: true,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
925
1073
|
// Multi-language: each translation carries its own hash
|
|
926
1074
|
if (event.translations) {
|
|
927
1075
|
for (const translation of Object.values(event.translations)) {
|
|
928
|
-
if (typeof translation
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
typeof translation.assetHash === 'string'
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1076
|
+
if (typeof translation !== 'object' || translation === null)
|
|
1077
|
+
continue;
|
|
1078
|
+
const assetHash = 'assetHash' in translation &&
|
|
1079
|
+
typeof translation.assetHash === 'string'
|
|
1080
|
+
? translation.assetHash
|
|
1081
|
+
: undefined;
|
|
1082
|
+
const assetPath = 'assetPath' in translation &&
|
|
1083
|
+
typeof translation.assetPath === 'string'
|
|
1084
|
+
? translation.assetPath
|
|
1085
|
+
: undefined;
|
|
1086
|
+
if (assetHash !== undefined) {
|
|
1087
|
+
if (assets.has(`hash:${assetHash}`))
|
|
1088
|
+
continue;
|
|
1089
|
+
const resolvedFile = assetPath !== undefined
|
|
1090
|
+
? await readRecordingFile(assetPath, configDir, sourceFilePath)
|
|
936
1091
|
: null;
|
|
937
|
-
assets.set(`hash:${
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1092
|
+
assets.set(`hash:${assetHash}`, {
|
|
1093
|
+
kind: 'clip',
|
|
1094
|
+
fileHash: assetHash,
|
|
1095
|
+
path: assetPath ?? assetHash,
|
|
941
1096
|
size: resolvedFile?.buffer.byteLength ?? 0,
|
|
942
|
-
...(resolvedFile !== null
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1097
|
+
...(resolvedFile !== null
|
|
1098
|
+
? {
|
|
1099
|
+
fileBuffer: resolvedFile.buffer,
|
|
1100
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
1101
|
+
}
|
|
1102
|
+
: { assumedUploaded: true }),
|
|
946
1103
|
});
|
|
947
1104
|
}
|
|
1105
|
+
else if (assetPath !== undefined &&
|
|
1106
|
+
!assets.has(`path:${assetPath}`)) {
|
|
1107
|
+
const resolvedFile = await readRecordingFile(assetPath, configDir, sourceFilePath);
|
|
1108
|
+
if (resolvedFile === null) {
|
|
1109
|
+
assets.set(`path:${assetPath}`, {
|
|
1110
|
+
kind: 'clip',
|
|
1111
|
+
fileHash: '',
|
|
1112
|
+
path: assetPath,
|
|
1113
|
+
size: 0,
|
|
1114
|
+
needsResolve: true,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
948
1118
|
}
|
|
949
1119
|
}
|
|
950
1120
|
}
|
|
@@ -954,23 +1124,47 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
954
1124
|
}
|
|
955
1125
|
return [...assets.values()];
|
|
956
1126
|
}
|
|
957
|
-
export function stripVoicePath(voice) {
|
|
1127
|
+
export function stripVoicePath(voice, byPath) {
|
|
958
1128
|
if (typeof voice !== 'string') {
|
|
959
|
-
|
|
1129
|
+
// A voice recovered from a previous upload has no record-time assetHash; fill
|
|
1130
|
+
// it in from the resolved-by-path map before the path is dropped.
|
|
1131
|
+
const assetHash = voice.assetHash ??
|
|
1132
|
+
(byPath !== undefined && typeof voice.assetPath === 'string'
|
|
1133
|
+
? byPath.get(voice.assetPath)
|
|
1134
|
+
: undefined);
|
|
1135
|
+
return { assetHash: assetHash };
|
|
960
1136
|
}
|
|
961
1137
|
return voice;
|
|
962
1138
|
}
|
|
963
1139
|
export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
1140
|
+
// Overlays are matched to their hash by name; every other asset kind (audio,
|
|
1141
|
+
// narration clip, custom voice) is matched by its file path. Skip placeholder
|
|
1142
|
+
// hashes that were never resolved so a missing entry stays untouched.
|
|
964
1143
|
const byName = new Map();
|
|
1144
|
+
const byPath = new Map();
|
|
965
1145
|
for (const asset of assets) {
|
|
1146
|
+
if (asset.fileHash.length === 0)
|
|
1147
|
+
continue;
|
|
966
1148
|
if (typeof asset.name === 'string')
|
|
967
1149
|
byName.set(asset.name, asset.fileHash);
|
|
1150
|
+
byPath.set(asset.path, asset.fileHash);
|
|
968
1151
|
}
|
|
969
1152
|
return {
|
|
970
1153
|
...data,
|
|
971
1154
|
events: data.events.map((event) => {
|
|
972
1155
|
if (event.type === 'assetStart') {
|
|
973
|
-
|
|
1156
|
+
if ('studio' in event || 'dependency' in event)
|
|
1157
|
+
return event;
|
|
1158
|
+
const fileHash = byName.get(event.name) ?? event.fileHash;
|
|
1159
|
+
return fileHash ? { ...event, fileHash } : event;
|
|
1160
|
+
}
|
|
1161
|
+
if (event.type === 'audioStart') {
|
|
1162
|
+
// Studio audio tracks carry no local path; leave them untouched.
|
|
1163
|
+
if (!('path' in event))
|
|
1164
|
+
return event;
|
|
1165
|
+
if (event.fileHash)
|
|
1166
|
+
return event;
|
|
1167
|
+
const fileHash = byPath.get(event.path);
|
|
974
1168
|
return fileHash ? { ...event, fileHash } : event;
|
|
975
1169
|
}
|
|
976
1170
|
if (event.type === 'cueStart' && event.translations) {
|
|
@@ -982,7 +1176,7 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
982
1176
|
language,
|
|
983
1177
|
{
|
|
984
1178
|
...translation,
|
|
985
|
-
voice: stripVoicePath(translation.voice),
|
|
1179
|
+
voice: stripVoicePath(translation.voice, byPath),
|
|
986
1180
|
},
|
|
987
1181
|
];
|
|
988
1182
|
}));
|
|
@@ -990,12 +1184,21 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
990
1184
|
}
|
|
991
1185
|
if (event.type !== 'videoCueStart')
|
|
992
1186
|
return event;
|
|
993
|
-
// Strip assetPath from translations
|
|
1187
|
+
// Strip assetPath from translations. The hash was either computed during
|
|
1188
|
+
// recording or recovered from a previous upload (matched by that path).
|
|
994
1189
|
if (event.translations) {
|
|
995
1190
|
const translations = Object.fromEntries(Object.entries(event.translations).map(([language, translation]) => {
|
|
996
|
-
if ('assetHash' in translation) {
|
|
997
|
-
const
|
|
998
|
-
|
|
1191
|
+
if ('assetHash' in translation || 'assetPath' in translation) {
|
|
1192
|
+
const file = translation;
|
|
1193
|
+
const assetHash = file.assetHash ??
|
|
1194
|
+
(file.assetPath !== undefined
|
|
1195
|
+
? byPath.get(file.assetPath)
|
|
1196
|
+
: undefined);
|
|
1197
|
+
const { assetPath: _removed, ...rest } = file;
|
|
1198
|
+
return [
|
|
1199
|
+
language,
|
|
1200
|
+
assetHash !== undefined ? { ...rest, assetHash } : rest,
|
|
1201
|
+
];
|
|
999
1202
|
}
|
|
1000
1203
|
if ('voice' in translation) {
|
|
1001
1204
|
return [
|
|
@@ -1003,7 +1206,7 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
1003
1206
|
{
|
|
1004
1207
|
...translation,
|
|
1005
1208
|
...(translation.voice !== undefined
|
|
1006
|
-
? { voice: stripVoicePath(translation.voice) }
|
|
1209
|
+
? { voice: stripVoicePath(translation.voice, byPath) }
|
|
1007
1210
|
: {}),
|
|
1008
1211
|
},
|
|
1009
1212
|
];
|
|
@@ -1012,10 +1215,15 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
1012
1215
|
}));
|
|
1013
1216
|
return { ...event, translations };
|
|
1014
1217
|
}
|
|
1015
|
-
// Single-language:
|
|
1016
|
-
|
|
1218
|
+
// Single-language: keep the assetHash (recovering it by path if needed) and
|
|
1219
|
+
// drop the now-redundant assetPath.
|
|
1220
|
+
const assetHash = event.assetHash ??
|
|
1221
|
+
(typeof event.assetPath === 'string'
|
|
1222
|
+
? byPath.get(event.assetPath)
|
|
1223
|
+
: undefined);
|
|
1224
|
+
if (typeof assetHash === 'string') {
|
|
1017
1225
|
const { assetPath: _removed, ...rest } = event;
|
|
1018
|
-
return rest;
|
|
1226
|
+
return { ...rest, assetHash };
|
|
1019
1227
|
}
|
|
1020
1228
|
return event;
|
|
1021
1229
|
}),
|
|
@@ -1027,6 +1235,43 @@ function hint401(status, secret) {
|
|
|
1027
1235
|
const frontendUrl = getDevFrontendUrl();
|
|
1028
1236
|
return `\nThe secret may have been deleted. Check your secrets at ${frontendUrl}/secrets`;
|
|
1029
1237
|
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Reduces a backend error response to its human-readable message. Backend
|
|
1240
|
+
* failures reply with a JSON body like `{"error":"..."}`; this returns just the
|
|
1241
|
+
* `error` string so failures print the message, not the raw JSON. Non-JSON or
|
|
1242
|
+
* shapeless bodies fall back to the original text unchanged.
|
|
1243
|
+
*/
|
|
1244
|
+
export function extractBackendError(responseText) {
|
|
1245
|
+
if (responseText.trim().length === 0)
|
|
1246
|
+
return responseText;
|
|
1247
|
+
try {
|
|
1248
|
+
const parsed = JSON.parse(responseText);
|
|
1249
|
+
if (typeof parsed.error === 'string' && parsed.error.trim().length > 0) {
|
|
1250
|
+
return parsed.error;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
catch {
|
|
1254
|
+
// Not JSON: fall back to the raw response text.
|
|
1255
|
+
}
|
|
1256
|
+
return responseText;
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Whether an upload-start error body flags a missing ElevenLabs key. The backend
|
|
1260
|
+
* fails the render immediately and replies with `{ elevenLabsKeyMissing: true }`
|
|
1261
|
+
* so the CLI can surface the dedicated Secrets-link error instead of a generic
|
|
1262
|
+
* upload failure. Non-JSON or shapeless bodies are treated as not flagged.
|
|
1263
|
+
*/
|
|
1264
|
+
export function responseFlagsElevenLabsKeyMissing(responseText) {
|
|
1265
|
+
if (responseText.trim().length === 0)
|
|
1266
|
+
return false;
|
|
1267
|
+
try {
|
|
1268
|
+
const parsed = JSON.parse(responseText);
|
|
1269
|
+
return parsed.elevenLabsKeyMissing === true;
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1030
1275
|
export function formatUploadStartFailureMessage(videoName, status, responseText, secret) {
|
|
1031
1276
|
if (responseText.trim().length > 0) {
|
|
1032
1277
|
try {
|
|
@@ -1067,6 +1312,80 @@ export function displayAssetPath(assetPath) {
|
|
|
1067
1312
|
const rel = pathRelative(process.cwd(), assetPath);
|
|
1068
1313
|
return rel.length > 0 ? rel : assetPath;
|
|
1069
1314
|
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Recovers the identity of assets whose local file was absent during collection
|
|
1317
|
+
* (no bytes, and for overlays no hash) by matching them, by path or name,
|
|
1318
|
+
* against a previous upload of the same video. Each resolved asset is mutated in
|
|
1319
|
+
* place: its `fileHash`, `size`, and `contentType` are filled in and it is
|
|
1320
|
+
* flagged `assumedUploaded` so the later existence check confirms the bytes are
|
|
1321
|
+
* still stored rather than trying to upload absent bytes.
|
|
1322
|
+
*
|
|
1323
|
+
* Returns the assets that could not be resolved (no previous version), so the
|
|
1324
|
+
* caller can fail the recording with actionable guidance.
|
|
1325
|
+
*/
|
|
1326
|
+
export async function resolveMissingUploadAssets(assets, projectName, videoName, apiUrl, secret, signal, progressReporter) {
|
|
1327
|
+
const pending = assets.filter((asset) => asset.needsResolve === true);
|
|
1328
|
+
if (pending.length === 0)
|
|
1329
|
+
return [];
|
|
1330
|
+
const refs = pending.map((asset) => ({
|
|
1331
|
+
path: asset.path,
|
|
1332
|
+
...(typeof asset.name === 'string' ? { name: asset.name } : {}),
|
|
1333
|
+
kind: asset.kind,
|
|
1334
|
+
}));
|
|
1335
|
+
const res = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/resolve-assets`, {
|
|
1336
|
+
method: 'POST',
|
|
1337
|
+
headers: {
|
|
1338
|
+
'Content-Type': 'application/json',
|
|
1339
|
+
'X-ScreenCI-Secret': secret,
|
|
1340
|
+
},
|
|
1341
|
+
body: JSON.stringify({ projectName, videoName, assets: refs }),
|
|
1342
|
+
signal,
|
|
1343
|
+
}), signal);
|
|
1344
|
+
if (!res.ok) {
|
|
1345
|
+
const text = await res.text();
|
|
1346
|
+
throw new UploadAssetError(`Failed to resolve previously uploaded assets for "${videoName}": ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1347
|
+
}
|
|
1348
|
+
const body = (await res.json());
|
|
1349
|
+
const resolved = Array.isArray(body.resolved) ? body.resolved : [];
|
|
1350
|
+
const logInfo = (message) => {
|
|
1351
|
+
if (progressReporter)
|
|
1352
|
+
progressReporter.info(message);
|
|
1353
|
+
else
|
|
1354
|
+
logger.info(message);
|
|
1355
|
+
};
|
|
1356
|
+
const unresolved = [];
|
|
1357
|
+
pending.forEach((asset, index) => {
|
|
1358
|
+
const match = resolved[index];
|
|
1359
|
+
if (match && typeof match.fileHash === 'string') {
|
|
1360
|
+
asset.fileHash = match.fileHash;
|
|
1361
|
+
asset.size = match.size ?? 0;
|
|
1362
|
+
if (typeof match.contentType === 'string') {
|
|
1363
|
+
asset.contentType = match.contentType;
|
|
1364
|
+
}
|
|
1365
|
+
asset.needsResolve = false;
|
|
1366
|
+
asset.assumedUploaded = true;
|
|
1367
|
+
logInfo(`${pc.green('✔')} Locally missing ${UPLOAD_ASSET_LABEL[asset.kind].toLowerCase()}, reusing the previously uploaded version: ${displayAssetPath(asset.path)}`);
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
unresolved.push(asset);
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
return unresolved;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Builds the failure message shown when locally missing assets have no
|
|
1377
|
+
* previously uploaded version to reuse.
|
|
1378
|
+
*/
|
|
1379
|
+
export function formatUnresolvedAssetMessage(videoName, unresolved) {
|
|
1380
|
+
const list = unresolved
|
|
1381
|
+
.map((asset) => ` - ${UPLOAD_ASSET_LABEL[asset.kind]}: ${asset.path}`)
|
|
1382
|
+
.join('\n');
|
|
1383
|
+
return [
|
|
1384
|
+
`Some asset files are missing locally and no previously uploaded version was found for "${videoName}":`,
|
|
1385
|
+
list,
|
|
1386
|
+
'Record once with these files present so they are uploaded, or commit them so they are available here.',
|
|
1387
|
+
].join('\n');
|
|
1388
|
+
}
|
|
1070
1389
|
async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted, progressReporter) {
|
|
1071
1390
|
const logInfo = (message) => {
|
|
1072
1391
|
if (progressReporter) {
|
|
@@ -1078,6 +1397,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1078
1397
|
};
|
|
1079
1398
|
for (const asset of assets) {
|
|
1080
1399
|
throwIfAborted();
|
|
1400
|
+
const label = UPLOAD_ASSET_LABEL[asset.kind];
|
|
1081
1401
|
try {
|
|
1082
1402
|
if (!asset.alwaysUpload) {
|
|
1083
1403
|
const checkRes = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/${recordingId}/asset/check`, {
|
|
@@ -1097,16 +1417,23 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1097
1417
|
}), signal);
|
|
1098
1418
|
if (!checkRes.ok) {
|
|
1099
1419
|
const text = await checkRes.text();
|
|
1100
|
-
throw new UploadAssetError(`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`);
|
|
1420
|
+
throw new UploadAssetError(`Failed to check asset ${displayAssetPath(asset.path)}: ${checkRes.status} ${extractBackendError(text)}${hint401(checkRes.status, secret)}`);
|
|
1101
1421
|
}
|
|
1102
1422
|
const checkBody = (await checkRes.json());
|
|
1103
1423
|
if (checkBody.exists) {
|
|
1104
|
-
logInfo(
|
|
1424
|
+
logInfo(asset.assumedUploaded
|
|
1425
|
+
? `${pc.green('✔')} Locally missing ${label.toLowerCase()}, already uploaded: ${displayAssetPath(asset.path)}`
|
|
1426
|
+
: `${pc.green('✔')} ${label} already exists: ${displayAssetPath(asset.path)}`);
|
|
1105
1427
|
continue;
|
|
1106
1428
|
}
|
|
1107
1429
|
}
|
|
1108
1430
|
if (!asset.fileBuffer || !asset.contentType) {
|
|
1109
|
-
|
|
1431
|
+
// A locally missing asset matched a previous upload by path/name, but its
|
|
1432
|
+
// bytes are not in this environment's storage. There is nothing to push.
|
|
1433
|
+
if (asset.assumedUploaded) {
|
|
1434
|
+
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.`);
|
|
1435
|
+
}
|
|
1436
|
+
throw new UploadAssetError(`Asset bytes not available for upload and backend does not have it yet: ${displayAssetPath(asset.path)}`);
|
|
1110
1437
|
}
|
|
1111
1438
|
const fileBuffer = asset.fileBuffer;
|
|
1112
1439
|
const contentType = asset.contentType;
|
|
@@ -1131,14 +1458,14 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1131
1458
|
if (!res.ok) {
|
|
1132
1459
|
const text = await res.text();
|
|
1133
1460
|
if (res.status === 409 && text.includes('already exists')) {
|
|
1134
|
-
logInfo(`${pc.green('✔')}
|
|
1461
|
+
logInfo(`${pc.green('✔')} ${label} already exists: ${displayAssetPath(asset.path)}`);
|
|
1135
1462
|
}
|
|
1136
1463
|
else {
|
|
1137
|
-
throw new UploadAssetError(`Failed to upload asset ${asset.path}: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
1464
|
+
throw new UploadAssetError(`Failed to upload asset ${displayAssetPath(asset.path)}: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1138
1465
|
}
|
|
1139
1466
|
}
|
|
1140
1467
|
else {
|
|
1141
|
-
logInfo(
|
|
1468
|
+
logInfo(`${pc.green('✔')} ${label} uploaded: ${displayAssetPath(asset.path)}`);
|
|
1142
1469
|
}
|
|
1143
1470
|
}
|
|
1144
1471
|
catch (err) {
|
|
@@ -1148,7 +1475,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1148
1475
|
if (isUploadAssetError(err)) {
|
|
1149
1476
|
throw err;
|
|
1150
1477
|
}
|
|
1151
|
-
throw new UploadAssetError(`Network error uploading asset ${asset.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1478
|
+
throw new UploadAssetError(`Network error uploading asset ${displayAssetPath(asset.path)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1152
1479
|
}
|
|
1153
1480
|
}
|
|
1154
1481
|
}
|
|
@@ -1168,6 +1495,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1168
1495
|
failedVideoNames: [],
|
|
1169
1496
|
failedVideoMessages: [],
|
|
1170
1497
|
studioNotices: [],
|
|
1498
|
+
elevenLabsKeyMissingVideos: [],
|
|
1499
|
+
notices: [],
|
|
1171
1500
|
plan: null,
|
|
1172
1501
|
};
|
|
1173
1502
|
}
|
|
@@ -1175,7 +1504,6 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1175
1504
|
entries = entries.filter((e) => e === specificEntry);
|
|
1176
1505
|
}
|
|
1177
1506
|
let firstProjectId = null;
|
|
1178
|
-
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
|
|
1179
1507
|
try {
|
|
1180
1508
|
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
1181
1509
|
uploadAbort.throwIfAborted();
|
|
@@ -1189,6 +1517,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1189
1517
|
failedVideoNames: [],
|
|
1190
1518
|
failedVideoMessages: [],
|
|
1191
1519
|
studioNotices: [],
|
|
1520
|
+
elevenLabsKeyMissingVideos: [],
|
|
1521
|
+
notices: [],
|
|
1192
1522
|
plan: null,
|
|
1193
1523
|
};
|
|
1194
1524
|
}
|
|
@@ -1197,7 +1527,7 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1197
1527
|
// waits for all of them to land before dispatching the batch, so it needs
|
|
1198
1528
|
// to know how many to expect.
|
|
1199
1529
|
const screenshotCount = candidates.filter((candidate) => candidate.data.output === 'screenshot').length;
|
|
1200
|
-
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret,
|
|
1530
|
+
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, index, recordId, screenshotCount)));
|
|
1201
1531
|
firstProjectId =
|
|
1202
1532
|
results.find((result) => result.projectId !== null)?.projectId ?? null;
|
|
1203
1533
|
const resolvedPlan = results.find((result) => result.plan !== undefined)?.plan ?? null;
|
|
@@ -1217,6 +1547,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1217
1547
|
},
|
|
1218
1548
|
]
|
|
1219
1549
|
: []);
|
|
1550
|
+
const elevenLabsKeyMissingVideos = results.flatMap((result) => result.elevenLabsKeyMissing === true ? [result.videoName] : []);
|
|
1551
|
+
const notices = results.flatMap((result) => !result.hadFailure && result.notices !== undefined ? result.notices : []);
|
|
1220
1552
|
return {
|
|
1221
1553
|
projectId: firstProjectId,
|
|
1222
1554
|
recordId,
|
|
@@ -1224,6 +1556,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1224
1556
|
failedVideoNames,
|
|
1225
1557
|
failedVideoMessages,
|
|
1226
1558
|
studioNotices,
|
|
1559
|
+
elevenLabsKeyMissingVideos,
|
|
1560
|
+
notices,
|
|
1227
1561
|
plan: resolvedPlan,
|
|
1228
1562
|
};
|
|
1229
1563
|
}
|
|
@@ -1344,19 +1678,29 @@ function isMissingFileError(err) {
|
|
|
1344
1678
|
}
|
|
1345
1679
|
async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
|
|
1346
1680
|
try {
|
|
1347
|
-
const
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
: resolve(dirname(resolvedConfigPath), '.env'), warnOnFailure);
|
|
1681
|
+
const envFilePath = (await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
|
|
1682
|
+
resolve(dirname(resolvedConfigPath), '.env');
|
|
1683
|
+
loadEnvFile(envFilePath, warnOnFailure);
|
|
1351
1684
|
}
|
|
1352
1685
|
catch {
|
|
1353
1686
|
// Config import may require Playwright context or dynamic values. Continue with
|
|
1354
1687
|
// the existing process env; Playwright will still load the config normally.
|
|
1355
1688
|
}
|
|
1356
1689
|
}
|
|
1357
|
-
|
|
1690
|
+
// Resolve `envFile` the way Playwright resolves a config value: by evaluating
|
|
1691
|
+
// the config module, not by scraping its source text. A dynamic
|
|
1692
|
+
// `envFile: isLocal ? '.env.local' : '.env'` only has a real value once the
|
|
1693
|
+
// module runs, so evaluating it lets local and prod setups pick the right file
|
|
1694
|
+
// (scraping the source can only see a plain string literal, and silently falls
|
|
1695
|
+
// back to `.env`). The environment the expression depends on (e.g.
|
|
1696
|
+
// SCREENCI_ENVIRONMENT, set by the `screenci:*:local` scripts) is already in
|
|
1697
|
+
// place before this runs, so the CLI resolves the same file Playwright will
|
|
1698
|
+
// when it later evaluates the same config. `loadRecordConfigWithoutPlaywright
|
|
1699
|
+
// Collision` only falls back to static source parsing if the module cannot be
|
|
1700
|
+
// imported.
|
|
1701
|
+
export async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
|
|
1358
1702
|
try {
|
|
1359
|
-
const screenciConfig = await
|
|
1703
|
+
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
1360
1704
|
if (!screenciConfig.envFile)
|
|
1361
1705
|
return undefined;
|
|
1362
1706
|
return resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
|
|
@@ -1493,7 +1837,7 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
|
1493
1837
|
});
|
|
1494
1838
|
if (!res.ok) {
|
|
1495
1839
|
const text = await res.text();
|
|
1496
|
-
throw new Error(`Failed to ${isPublic ? 'make public' : 'make private'}: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
1840
|
+
throw new Error(`Failed to ${isPublic ? 'make public' : 'make private'}: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1497
1841
|
}
|
|
1498
1842
|
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
1499
1843
|
}
|
|
@@ -1535,7 +1879,7 @@ async function triggerRemoteRun(configPath, grep, languages) {
|
|
|
1535
1879
|
});
|
|
1536
1880
|
if (!res.ok) {
|
|
1537
1881
|
const text = await res.text();
|
|
1538
|
-
throw new Error(`Failed to trigger remote run: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
1882
|
+
throw new Error(`Failed to trigger remote run: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1539
1883
|
}
|
|
1540
1884
|
const filters = [
|
|
1541
1885
|
...(grep ? [`filter: ${grep}`] : []),
|
|
@@ -1596,7 +1940,7 @@ async function printInfo(configPath) {
|
|
|
1596
1940
|
});
|
|
1597
1941
|
if (!res.ok) {
|
|
1598
1942
|
const text = await res.text();
|
|
1599
|
-
throw new Error(`Failed to fetch info: ${res.status} ${text}${hint401(res.status, secret)}`);
|
|
1943
|
+
throw new Error(`Failed to fetch info: ${res.status} ${extractBackendError(text)}${hint401(res.status, secret)}`);
|
|
1600
1944
|
}
|
|
1601
1945
|
const data = await res.json();
|
|
1602
1946
|
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
@@ -1863,6 +2207,144 @@ export async function runLogin(configPath) {
|
|
|
1863
2207
|
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()}).`);
|
|
1864
2208
|
}
|
|
1865
2209
|
}
|
|
2210
|
+
// Uploads the recordings already written under `.screenci` for the resolved
|
|
2211
|
+
// config. Shared by `record` (after a Playwright run) and `retry` (which
|
|
2212
|
+
// re-sends the existing recordings without re-running Playwright). A non-null
|
|
2213
|
+
// `playwrightFailure` means the preceding record run had failures, which tunes
|
|
2214
|
+
// the messaging and the upload policy; `retry` always passes null.
|
|
2215
|
+
async function uploadRecordedVideosForConfig(configPath, playwrightFailure, verbose) {
|
|
2216
|
+
// After recording, upload results to API if configured. `run` already
|
|
2217
|
+
// resolved the config (or exited), so this best-effort lookup only acts
|
|
2218
|
+
// when a flat config is present in/under the current directory.
|
|
2219
|
+
const resolution = findScreenCIConfig(configPath);
|
|
2220
|
+
if (resolution.kind !== 'found')
|
|
2221
|
+
return;
|
|
2222
|
+
const resolvedConfigPath = resolution.path;
|
|
2223
|
+
try {
|
|
2224
|
+
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
2225
|
+
loadEnvFile(screenciConfig.envFile
|
|
2226
|
+
? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
|
|
2227
|
+
: resolve(dirname(resolvedConfigPath), '.env'), true);
|
|
2228
|
+
const apiUrl = getDevBackendUrl();
|
|
2229
|
+
const appUrl = getDevFrontendUrl();
|
|
2230
|
+
const secret = process.env.SCREENCI_SECRET;
|
|
2231
|
+
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig);
|
|
2232
|
+
const configDir = dirname(resolvedConfigPath);
|
|
2233
|
+
const screenciDir = resolve(configDir, '.screenci');
|
|
2234
|
+
const completedRecordingCount = await countCompletedRecordings(screenciDir);
|
|
2235
|
+
if (playwrightFailure !== null && completedRecordingCount === 0) {
|
|
2236
|
+
logger.info('All recordings failed.');
|
|
2237
|
+
}
|
|
2238
|
+
else if (!secret) {
|
|
2239
|
+
logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
|
|
2240
|
+
}
|
|
2241
|
+
else if (playwrightFailure !== null &&
|
|
2242
|
+
uploadPolicy === 'all-or-nothing') {
|
|
2243
|
+
logger.info('Some recordings failed, skipping upload because record.upload is "all-or-nothing".');
|
|
2244
|
+
}
|
|
2245
|
+
else {
|
|
2246
|
+
if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
|
|
2247
|
+
logger.warn('Some recordings failed, uploading successful videos only.');
|
|
2248
|
+
}
|
|
2249
|
+
let uploadResult = {
|
|
2250
|
+
projectId: null,
|
|
2251
|
+
recordId: null,
|
|
2252
|
+
hadFailures: false,
|
|
2253
|
+
failedVideoNames: [],
|
|
2254
|
+
failedVideoMessages: [],
|
|
2255
|
+
studioNotices: [],
|
|
2256
|
+
elevenLabsKeyMissingVideos: [],
|
|
2257
|
+
notices: [],
|
|
2258
|
+
plan: null,
|
|
2259
|
+
};
|
|
2260
|
+
try {
|
|
2261
|
+
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret, undefined, verbose);
|
|
2262
|
+
}
|
|
2263
|
+
catch (err) {
|
|
2264
|
+
if (isUploadCancelledError(err)) {
|
|
2265
|
+
process.exit(130);
|
|
2266
|
+
}
|
|
2267
|
+
throw err;
|
|
2268
|
+
}
|
|
2269
|
+
const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, elevenLabsKeyMissingVideos, notices, plan, } = uploadResult;
|
|
2270
|
+
// Remember this run so `screenci info` can report exactly it.
|
|
2271
|
+
if (recordId !== null) {
|
|
2272
|
+
await saveLastRecordId(screenciDir, recordId);
|
|
2273
|
+
}
|
|
2274
|
+
// Emit upload-failure warnings (stderr) before the results block.
|
|
2275
|
+
// logger.info writes to stdout, logger.warn to stderr; in non-TTY CI
|
|
2276
|
+
// logs stdout is block-buffered while stderr flushes immediately, so
|
|
2277
|
+
// warnings printed after the "Results available at:" line would split
|
|
2278
|
+
// it from its URL. Reporting failures first keeps the URL directly
|
|
2279
|
+
// under its message.
|
|
2280
|
+
if (hadFailures) {
|
|
2281
|
+
for (const failedVideo of failedVideoMessages) {
|
|
2282
|
+
logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
|
|
2283
|
+
}
|
|
2284
|
+
logger.warn(`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`);
|
|
2285
|
+
}
|
|
2286
|
+
if (recordId !== null && projectId !== null) {
|
|
2287
|
+
const recordUrl = `${appUrl}/record/${recordId}`;
|
|
2288
|
+
await writeGitHubProjectOutput(recordUrl);
|
|
2289
|
+
logger.info('');
|
|
2290
|
+
logger.info(playwrightFailure !== null
|
|
2291
|
+
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
2292
|
+
: 'Recording finished, rendering in progress. Results available at:');
|
|
2293
|
+
logger.info(pc.cyan(recordUrl));
|
|
2294
|
+
}
|
|
2295
|
+
else if (projectId !== null) {
|
|
2296
|
+
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
2297
|
+
await writeGitHubProjectOutput(projectUrl);
|
|
2298
|
+
logger.info('');
|
|
2299
|
+
logger.info(playwrightFailure !== null
|
|
2300
|
+
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
2301
|
+
: 'Recording finished, rendering in progress. Results available at:');
|
|
2302
|
+
logger.info(pc.cyan(projectUrl));
|
|
2303
|
+
}
|
|
2304
|
+
if (notices.length > 0) {
|
|
2305
|
+
logger.info('');
|
|
2306
|
+
for (const notice of notices) {
|
|
2307
|
+
logger.notice(notice);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
if (projectId !== null && plan !== 'business') {
|
|
2311
|
+
logger.info('');
|
|
2312
|
+
logger.info('Upgrade for more renders, more active videos, and expressive narration:');
|
|
2313
|
+
logger.info(pc.cyan(`${appUrl}/select-plan`));
|
|
2314
|
+
}
|
|
2315
|
+
for (const notice of studioNotices) {
|
|
2316
|
+
if ('held' in notice.studio) {
|
|
2317
|
+
logger.info('');
|
|
2318
|
+
logger.info(`Rendering for "${notice.videoName}" is on hold. Configure it in Studio:`);
|
|
2319
|
+
if (projectId !== null && notice.videoId !== null) {
|
|
2320
|
+
logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
else if (notice.studio.applied) {
|
|
2324
|
+
logger.info('');
|
|
2325
|
+
logger.info(`Studio configuration applied for "${notice.videoName}".`);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
if (elevenLabsKeyMissingVideos.length > 0) {
|
|
2329
|
+
const names = elevenLabsKeyMissingVideos
|
|
2330
|
+
.map((name) => `"${name}"`)
|
|
2331
|
+
.join(', ');
|
|
2332
|
+
logger.info('');
|
|
2333
|
+
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:`);
|
|
2334
|
+
logger.info(pc.cyan(getScreenCISecretsUrl()));
|
|
2335
|
+
}
|
|
2336
|
+
if (hadFailures && playwrightFailure === null) {
|
|
2337
|
+
throw new PartialUploadError();
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
catch (err) {
|
|
2342
|
+
if (isPartialUploadError(err)) {
|
|
2343
|
+
throw err;
|
|
2344
|
+
}
|
|
2345
|
+
logger.warn('Failed to load config for upload:', err);
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
1866
2348
|
export async function main() {
|
|
1867
2349
|
if (process.argv.length <= 2) {
|
|
1868
2350
|
logger.error('Error: No command provided');
|
|
@@ -1894,130 +2376,32 @@ export async function main() {
|
|
|
1894
2376
|
return;
|
|
1895
2377
|
}
|
|
1896
2378
|
let playwrightFailure = null;
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
if (error.message.startsWith('Playwright exited with code ')) {
|
|
1904
|
-
playwrightFailure = new RecordFailureHintError(error);
|
|
1905
|
-
}
|
|
1906
|
-
else {
|
|
1907
|
-
throw new RecordFailureHintError(error);
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
if (process.env.SCREENCI_RECORDING === 'true')
|
|
1911
|
-
return;
|
|
1912
|
-
// After recording, upload results to API if configured. `run` already
|
|
1913
|
-
// resolved the config (or exited), so this best-effort lookup only acts
|
|
1914
|
-
// when a flat config is present in/under the current directory.
|
|
1915
|
-
const resolution = findScreenCIConfig(parsed.configPath);
|
|
1916
|
-
if (resolution.kind === 'found') {
|
|
1917
|
-
const resolvedConfigPath = resolution.path;
|
|
2379
|
+
// UPLOAD_EXISTING re-sends the recordings already on disk under `.screenci`
|
|
2380
|
+
// without re-running Playwright (resend the last local run when only the
|
|
2381
|
+
// upload failed). We skip the recording run and fall straight through to
|
|
2382
|
+
// the upload below, treating the on-disk recordings as the complete set.
|
|
2383
|
+
const uploadExisting = isUploadExistingEnabled();
|
|
2384
|
+
if (!uploadExisting) {
|
|
1918
2385
|
try {
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
const uploadPolicy = resolveRecordUploadPolicy(screenciConfig);
|
|
1927
|
-
const configDir = dirname(resolvedConfigPath);
|
|
1928
|
-
const screenciDir = resolve(configDir, '.screenci');
|
|
1929
|
-
const completedRecordingCount = await countCompletedRecordings(screenciDir);
|
|
1930
|
-
if (playwrightFailure !== null && completedRecordingCount === 0) {
|
|
1931
|
-
logger.info('All recordings failed.');
|
|
1932
|
-
}
|
|
1933
|
-
else if (!secret) {
|
|
1934
|
-
logger.info(`No SCREENCI_SECRET configured for uploads. Rerun ${getSuggestedScreenciCommand('record')} or add it to the project env file.`);
|
|
1935
|
-
}
|
|
1936
|
-
else if (playwrightFailure !== null &&
|
|
1937
|
-
uploadPolicy === 'all-or-nothing') {
|
|
1938
|
-
logger.info('Some recordings failed, skipping upload because record.upload is "all-or-nothing".');
|
|
2386
|
+
await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose, false, parsed.pollAuth, parsed.languages, parsed.noPollAuth);
|
|
2387
|
+
}
|
|
2388
|
+
catch (error) {
|
|
2389
|
+
if (!(error instanceof Error))
|
|
2390
|
+
throw error;
|
|
2391
|
+
if (error.message.startsWith('Playwright exited with code ')) {
|
|
2392
|
+
playwrightFailure = new RecordFailureHintError(error);
|
|
1939
2393
|
}
|
|
1940
2394
|
else {
|
|
1941
|
-
|
|
1942
|
-
logger.warn('Some recordings failed, uploading successful videos only.');
|
|
1943
|
-
}
|
|
1944
|
-
let uploadResult = {
|
|
1945
|
-
projectId: null,
|
|
1946
|
-
recordId: null,
|
|
1947
|
-
hadFailures: false,
|
|
1948
|
-
failedVideoNames: [],
|
|
1949
|
-
failedVideoMessages: [],
|
|
1950
|
-
studioNotices: [],
|
|
1951
|
-
plan: null,
|
|
1952
|
-
};
|
|
1953
|
-
try {
|
|
1954
|
-
uploadResult = await uploadRecordings(screenciDir, screenciConfig.projectName, apiUrl, secret);
|
|
1955
|
-
}
|
|
1956
|
-
catch (err) {
|
|
1957
|
-
if (isUploadCancelledError(err)) {
|
|
1958
|
-
process.exit(130);
|
|
1959
|
-
}
|
|
1960
|
-
throw err;
|
|
1961
|
-
}
|
|
1962
|
-
const { projectId, recordId, hadFailures, failedVideoNames, failedVideoMessages, studioNotices, plan, } = uploadResult;
|
|
1963
|
-
// Remember this run so `screenci info` can report exactly it.
|
|
1964
|
-
if (recordId !== null) {
|
|
1965
|
-
await saveLastRecordId(screenciDir, recordId);
|
|
1966
|
-
}
|
|
1967
|
-
if (recordId !== null && projectId !== null) {
|
|
1968
|
-
const recordUrl = `${appUrl}/record/${recordId}`;
|
|
1969
|
-
await writeGitHubProjectOutput(recordUrl);
|
|
1970
|
-
logger.info('');
|
|
1971
|
-
logger.info(playwrightFailure !== null
|
|
1972
|
-
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
1973
|
-
: 'Recording finished, rendering in progress. Results available at:');
|
|
1974
|
-
logger.info(pc.cyan(recordUrl));
|
|
1975
|
-
}
|
|
1976
|
-
else if (projectId !== null) {
|
|
1977
|
-
const projectUrl = `${appUrl}/project/${projectId}`;
|
|
1978
|
-
await writeGitHubProjectOutput(projectUrl);
|
|
1979
|
-
logger.info('');
|
|
1980
|
-
logger.info(playwrightFailure !== null
|
|
1981
|
-
? 'Recording partially succeeded, rendering in progress. Results available at:'
|
|
1982
|
-
: 'Recording finished, rendering in progress. Results available at:');
|
|
1983
|
-
logger.info(pc.cyan(projectUrl));
|
|
1984
|
-
}
|
|
1985
|
-
if (projectId !== null && plan !== 'business') {
|
|
1986
|
-
logger.info('');
|
|
1987
|
-
logger.info('Upgrade for more renders, more active videos, and expressive narration:');
|
|
1988
|
-
logger.info(pc.cyan(`${appUrl}/select-plan`));
|
|
1989
|
-
}
|
|
1990
|
-
for (const notice of studioNotices) {
|
|
1991
|
-
if ('held' in notice.studio) {
|
|
1992
|
-
logger.info('');
|
|
1993
|
-
logger.info(`Rendering for "${notice.videoName}" is on hold — configure it in Studio:`);
|
|
1994
|
-
if (projectId !== null && notice.videoId !== null) {
|
|
1995
|
-
logger.info(pc.cyan(formatStudioUrl(appUrl, projectId, notice.videoId)));
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
else if (notice.studio.applied) {
|
|
1999
|
-
logger.info('');
|
|
2000
|
-
logger.info(`Studio configuration applied for "${notice.videoName}".`);
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2003
|
-
if (hadFailures) {
|
|
2004
|
-
for (const failedVideo of failedVideoMessages) {
|
|
2005
|
-
logger.warn(formatFailedVideoMessage(failedVideo.videoName, failedVideo.message));
|
|
2006
|
-
}
|
|
2007
|
-
logger.warn(`Not all recordings succeeded to upload. Failed videos: ${failedVideoNames.join(', ') || 'unknown'}. Some videos may be missing from the project.`);
|
|
2008
|
-
if (playwrightFailure === null) {
|
|
2009
|
-
throw new PartialUploadError();
|
|
2010
|
-
}
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
catch (err) {
|
|
2015
|
-
if (isPartialUploadError(err)) {
|
|
2016
|
-
throw err;
|
|
2395
|
+
throw new RecordFailureHintError(error);
|
|
2017
2396
|
}
|
|
2018
|
-
logger.warn('Failed to load config for upload:', err);
|
|
2019
2397
|
}
|
|
2020
2398
|
}
|
|
2399
|
+
else {
|
|
2400
|
+
logger.info('UPLOAD_EXISTING set: skipping Playwright recording and re-uploading existing .screenci recordings.');
|
|
2401
|
+
}
|
|
2402
|
+
if (process.env.SCREENCI_RECORDING === 'true')
|
|
2403
|
+
return;
|
|
2404
|
+
await uploadRecordedVideosForConfig(parsed.configPath, playwrightFailure, parsed.verbose);
|
|
2021
2405
|
if (playwrightFailure !== null) {
|
|
2022
2406
|
throw playwrightFailure;
|
|
2023
2407
|
}
|
|
@@ -2245,7 +2629,7 @@ function validateArgs(args) {
|
|
|
2245
2629
|
}
|
|
2246
2630
|
/**
|
|
2247
2631
|
* Fetch the project's current Studio text-field overrides so they can be
|
|
2248
|
-
* injected as
|
|
2632
|
+
* injected as SCREENCI_VALUES_OVERRIDES before a recording. On-screen `values`
|
|
2249
2633
|
* fields render during the recording (they cannot be patched at render time),
|
|
2250
2634
|
* so any Studio edits must be present here; code-declared seeds are the
|
|
2251
2635
|
* fallback. Best-effort: any failure returns an empty env so the SDK uses the
|
|
@@ -2274,7 +2658,7 @@ async function fetchTextOverridesEnv(configPath, languages, verbose) {
|
|
|
2274
2658
|
Object.keys(overrides).length === 0) {
|
|
2275
2659
|
return {};
|
|
2276
2660
|
}
|
|
2277
|
-
return { [
|
|
2661
|
+
return { [SCREENCI_VALUES_OVERRIDES_ENV]: JSON.stringify(overrides) };
|
|
2278
2662
|
}
|
|
2279
2663
|
catch (error) {
|
|
2280
2664
|
if (verbose) {
|