screenci 0.0.76 → 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 +51 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +557 -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 +14 -3
- 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 +64 -18
- 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,
|
|
@@ -430,6 +459,16 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
430
459
|
videoId = startBody.videoId ?? null;
|
|
431
460
|
plan = startBody.plan ?? null;
|
|
432
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.
|
|
433
472
|
if (verbose) {
|
|
434
473
|
logger.info(`recordingId=${recordingId} projectId=${projectId}`);
|
|
435
474
|
logger.info(`assets=${preparedUploadAssets.length} recordingHash=${recordingHash ?? 'none'}`);
|
|
@@ -453,9 +492,6 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
453
492
|
'Content-Type': recordingContentType,
|
|
454
493
|
'Content-Length': String(fileStat.size),
|
|
455
494
|
'X-ScreenCI-Secret': secret,
|
|
456
|
-
...(elevenLabsApiKey
|
|
457
|
-
? { 'X-ElevenLabs-Api-Key': elevenLabsApiKey }
|
|
458
|
-
: {}),
|
|
459
495
|
},
|
|
460
496
|
body: stream,
|
|
461
497
|
signal: uploadAbort.signal,
|
|
@@ -475,7 +511,7 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
475
511
|
videoId,
|
|
476
512
|
hadFailure: true,
|
|
477
513
|
videoName,
|
|
478
|
-
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)}`,
|
|
479
515
|
recordId,
|
|
480
516
|
...(plan !== null && { plan }),
|
|
481
517
|
};
|
|
@@ -490,6 +526,13 @@ async function uploadRecordingCandidate(candidate, screenciDir, projectName, api
|
|
|
490
526
|
recordId,
|
|
491
527
|
...(studio !== undefined && { studio }),
|
|
492
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
|
+
}),
|
|
493
536
|
};
|
|
494
537
|
}
|
|
495
538
|
catch (err) {
|
|
@@ -713,11 +756,17 @@ function forwardChildSignals(child, activityLabel, options = {}) {
|
|
|
713
756
|
getForwardedSignal: () => forwardedSignal,
|
|
714
757
|
};
|
|
715
758
|
}
|
|
716
|
-
function clearRecordingDirectories(dir) {
|
|
759
|
+
export function clearRecordingDirectories(dir) {
|
|
717
760
|
mkdirSync(dir, { recursive: true });
|
|
718
761
|
for (const entry of readdirSync(dir)) {
|
|
719
762
|
if (entry === SCREENCI_LINK_SESSION_FILE)
|
|
720
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;
|
|
721
770
|
rmSync(resolve(dir, entry), { recursive: true, force: true });
|
|
722
771
|
}
|
|
723
772
|
}
|
|
@@ -826,32 +875,56 @@ async function prepareCustomVoiceAssets(data, configDir) {
|
|
|
826
875
|
const resolvedFile = await readRecordingFile(voicePath, configDir, sourceFilePath);
|
|
827
876
|
if (resolvedFile === null) {
|
|
828
877
|
const existingHash = refs.find((ref) => typeof ref.assetHash === 'string')?.assetHash;
|
|
829
|
-
if (
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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;
|
|
835
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.
|
|
836
897
|
preparedAssets.push({
|
|
837
|
-
|
|
898
|
+
kind: 'voice',
|
|
899
|
+
fileHash: '',
|
|
838
900
|
path: voicePath,
|
|
839
901
|
size: 0,
|
|
840
902
|
contentType: contentTypeForPath(voicePath),
|
|
903
|
+
needsResolve: true,
|
|
841
904
|
});
|
|
842
905
|
continue;
|
|
843
906
|
}
|
|
844
907
|
const { buffer: fileBuffer, resolvedPath } = resolvedFile;
|
|
845
|
-
|
|
846
|
-
|
|
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');
|
|
847
918
|
for (const ref of refs) {
|
|
848
919
|
ref.assetHash = assetHash;
|
|
920
|
+
ref.sampleHash = sampleHash;
|
|
849
921
|
}
|
|
850
922
|
preparedAssets.push({
|
|
923
|
+
kind: 'voice',
|
|
851
924
|
fileHash: assetHash,
|
|
852
925
|
path: voicePath,
|
|
853
|
-
size:
|
|
854
|
-
fileBuffer,
|
|
926
|
+
size: uploadBuffer.byteLength,
|
|
927
|
+
fileBuffer: uploadBuffer,
|
|
855
928
|
contentType,
|
|
856
929
|
});
|
|
857
930
|
}
|
|
@@ -866,15 +939,29 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
866
939
|
// page and merged into the recording by the backend.
|
|
867
940
|
if ('studio' in event && event.studio === true)
|
|
868
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;
|
|
869
946
|
if (assets.has(`name:${event.name}`))
|
|
870
947
|
continue;
|
|
871
948
|
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
872
949
|
if (resolvedFile === null) {
|
|
873
|
-
|
|
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
|
+
});
|
|
874
960
|
continue;
|
|
875
961
|
}
|
|
876
962
|
const { buffer: fileBuffer, resolvedPath } = resolvedFile;
|
|
877
963
|
assets.set(`name:${event.name}`, {
|
|
964
|
+
kind: 'overlay',
|
|
878
965
|
fileHash: createHash('sha256').update(fileBuffer).digest('hex'),
|
|
879
966
|
path: event.path,
|
|
880
967
|
name: event.name,
|
|
@@ -888,15 +975,45 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
888
975
|
// Studio audio tracks have no local file.
|
|
889
976
|
if ('studio' in event && event.studio === true)
|
|
890
977
|
continue;
|
|
891
|
-
|
|
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))
|
|
892
984
|
continue;
|
|
893
985
|
const resolvedFile = await readRecordingFile(event.path, configDir, sourceFilePath);
|
|
894
986
|
if (resolvedFile === null) {
|
|
895
|
-
|
|
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
|
+
}
|
|
896
1011
|
continue;
|
|
897
1012
|
}
|
|
898
|
-
assets.set(
|
|
899
|
-
|
|
1013
|
+
assets.set(dedupKey, {
|
|
1014
|
+
kind: 'audio',
|
|
1015
|
+
fileHash: event.fileHash ??
|
|
1016
|
+
createHash('sha256').update(resolvedFile.buffer).digest('hex'),
|
|
900
1017
|
path: event.path,
|
|
901
1018
|
size: resolvedFile.buffer.byteLength,
|
|
902
1019
|
fileBuffer: resolvedFile.buffer,
|
|
@@ -913,38 +1030,79 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
913
1030
|
? await readRecordingFile(event.assetPath, configDir, sourceFilePath)
|
|
914
1031
|
: null;
|
|
915
1032
|
assets.set(`hash:${event.assetHash}`, {
|
|
1033
|
+
kind: 'clip',
|
|
916
1034
|
fileHash: event.assetHash,
|
|
917
1035
|
path: event.assetPath ?? event.assetHash,
|
|
918
1036
|
size: resolvedFile?.buffer.byteLength ?? 0,
|
|
919
|
-
...(resolvedFile !== null
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1037
|
+
...(resolvedFile !== null
|
|
1038
|
+
? {
|
|
1039
|
+
fileBuffer: resolvedFile.buffer,
|
|
1040
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
1041
|
+
}
|
|
1042
|
+
: { assumedUploaded: true }),
|
|
923
1043
|
});
|
|
924
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
|
+
}
|
|
925
1061
|
// Multi-language: each translation carries its own hash
|
|
926
1062
|
if (event.translations) {
|
|
927
1063
|
for (const translation of Object.values(event.translations)) {
|
|
928
|
-
if (typeof translation
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
typeof translation.assetHash === 'string'
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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)
|
|
936
1079
|
: null;
|
|
937
|
-
assets.set(`hash:${
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1080
|
+
assets.set(`hash:${assetHash}`, {
|
|
1081
|
+
kind: 'clip',
|
|
1082
|
+
fileHash: assetHash,
|
|
1083
|
+
path: assetPath ?? assetHash,
|
|
941
1084
|
size: resolvedFile?.buffer.byteLength ?? 0,
|
|
942
|
-
...(resolvedFile !== null
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1085
|
+
...(resolvedFile !== null
|
|
1086
|
+
? {
|
|
1087
|
+
fileBuffer: resolvedFile.buffer,
|
|
1088
|
+
contentType: contentTypeForPath(resolvedFile.resolvedPath),
|
|
1089
|
+
}
|
|
1090
|
+
: { assumedUploaded: true }),
|
|
946
1091
|
});
|
|
947
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
|
+
}
|
|
948
1106
|
}
|
|
949
1107
|
}
|
|
950
1108
|
}
|
|
@@ -954,23 +1112,47 @@ export async function collectUploadAssets(data, configDir) {
|
|
|
954
1112
|
}
|
|
955
1113
|
return [...assets.values()];
|
|
956
1114
|
}
|
|
957
|
-
export function stripVoicePath(voice) {
|
|
1115
|
+
export function stripVoicePath(voice, byPath) {
|
|
958
1116
|
if (typeof voice !== 'string') {
|
|
959
|
-
|
|
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 };
|
|
960
1124
|
}
|
|
961
1125
|
return voice;
|
|
962
1126
|
}
|
|
963
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.
|
|
964
1131
|
const byName = new Map();
|
|
1132
|
+
const byPath = new Map();
|
|
965
1133
|
for (const asset of assets) {
|
|
1134
|
+
if (asset.fileHash.length === 0)
|
|
1135
|
+
continue;
|
|
966
1136
|
if (typeof asset.name === 'string')
|
|
967
1137
|
byName.set(asset.name, asset.fileHash);
|
|
1138
|
+
byPath.set(asset.path, asset.fileHash);
|
|
968
1139
|
}
|
|
969
1140
|
return {
|
|
970
1141
|
...data,
|
|
971
1142
|
events: data.events.map((event) => {
|
|
972
1143
|
if (event.type === 'assetStart') {
|
|
973
|
-
|
|
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);
|
|
974
1156
|
return fileHash ? { ...event, fileHash } : event;
|
|
975
1157
|
}
|
|
976
1158
|
if (event.type === 'cueStart' && event.translations) {
|
|
@@ -982,7 +1164,7 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
982
1164
|
language,
|
|
983
1165
|
{
|
|
984
1166
|
...translation,
|
|
985
|
-
voice: stripVoicePath(translation.voice),
|
|
1167
|
+
voice: stripVoicePath(translation.voice, byPath),
|
|
986
1168
|
},
|
|
987
1169
|
];
|
|
988
1170
|
}));
|
|
@@ -990,12 +1172,21 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
990
1172
|
}
|
|
991
1173
|
if (event.type !== 'videoCueStart')
|
|
992
1174
|
return event;
|
|
993
|
-
// 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).
|
|
994
1177
|
if (event.translations) {
|
|
995
1178
|
const translations = Object.fromEntries(Object.entries(event.translations).map(([language, translation]) => {
|
|
996
|
-
if ('assetHash' in translation) {
|
|
997
|
-
const
|
|
998
|
-
|
|
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
|
+
];
|
|
999
1190
|
}
|
|
1000
1191
|
if ('voice' in translation) {
|
|
1001
1192
|
return [
|
|
@@ -1003,7 +1194,7 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
1003
1194
|
{
|
|
1004
1195
|
...translation,
|
|
1005
1196
|
...(translation.voice !== undefined
|
|
1006
|
-
? { voice: stripVoicePath(translation.voice) }
|
|
1197
|
+
? { voice: stripVoicePath(translation.voice, byPath) }
|
|
1007
1198
|
: {}),
|
|
1008
1199
|
},
|
|
1009
1200
|
];
|
|
@@ -1012,10 +1203,15 @@ export function annotateRecordingDataWithAssetHashes(data, assets) {
|
|
|
1012
1203
|
}));
|
|
1013
1204
|
return { ...event, translations };
|
|
1014
1205
|
}
|
|
1015
|
-
// Single-language:
|
|
1016
|
-
|
|
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') {
|
|
1017
1213
|
const { assetPath: _removed, ...rest } = event;
|
|
1018
|
-
return rest;
|
|
1214
|
+
return { ...rest, assetHash };
|
|
1019
1215
|
}
|
|
1020
1216
|
return event;
|
|
1021
1217
|
}),
|
|
@@ -1027,6 +1223,26 @@ function hint401(status, secret) {
|
|
|
1027
1223
|
const frontendUrl = getDevFrontendUrl();
|
|
1028
1224
|
return `\nThe secret may have been deleted. Check your secrets at ${frontendUrl}/secrets`;
|
|
1029
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;
|
|
1245
|
+
}
|
|
1030
1246
|
export function formatUploadStartFailureMessage(videoName, status, responseText, secret) {
|
|
1031
1247
|
if (responseText.trim().length > 0) {
|
|
1032
1248
|
try {
|
|
@@ -1067,6 +1283,80 @@ export function displayAssetPath(assetPath) {
|
|
|
1067
1283
|
const rel = pathRelative(process.cwd(), assetPath);
|
|
1068
1284
|
return rel.length > 0 ? rel : assetPath;
|
|
1069
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
|
+
}
|
|
1070
1360
|
async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIfAborted, progressReporter) {
|
|
1071
1361
|
const logInfo = (message) => {
|
|
1072
1362
|
if (progressReporter) {
|
|
@@ -1078,6 +1368,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1078
1368
|
};
|
|
1079
1369
|
for (const asset of assets) {
|
|
1080
1370
|
throwIfAborted();
|
|
1371
|
+
const label = UPLOAD_ASSET_LABEL[asset.kind];
|
|
1081
1372
|
try {
|
|
1082
1373
|
if (!asset.alwaysUpload) {
|
|
1083
1374
|
const checkRes = await withUploadRetry(() => fetch(`${apiUrl}/cli/upload/${recordingId}/asset/check`, {
|
|
@@ -1097,16 +1388,23 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1097
1388
|
}), signal);
|
|
1098
1389
|
if (!checkRes.ok) {
|
|
1099
1390
|
const text = await checkRes.text();
|
|
1100
|
-
throw new UploadAssetError(`Failed to check asset ${asset.path}: ${checkRes.status} ${text}${hint401(checkRes.status, secret)}`);
|
|
1391
|
+
throw new UploadAssetError(`Failed to check asset ${displayAssetPath(asset.path)}: ${checkRes.status} ${extractBackendError(text)}${hint401(checkRes.status, secret)}`);
|
|
1101
1392
|
}
|
|
1102
1393
|
const checkBody = (await checkRes.json());
|
|
1103
1394
|
if (checkBody.exists) {
|
|
1104
|
-
logInfo(
|
|
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)}`);
|
|
1105
1398
|
continue;
|
|
1106
1399
|
}
|
|
1107
1400
|
}
|
|
1108
1401
|
if (!asset.fileBuffer || !asset.contentType) {
|
|
1109
|
-
|
|
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)}`);
|
|
1110
1408
|
}
|
|
1111
1409
|
const fileBuffer = asset.fileBuffer;
|
|
1112
1410
|
const contentType = asset.contentType;
|
|
@@ -1131,14 +1429,14 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1131
1429
|
if (!res.ok) {
|
|
1132
1430
|
const text = await res.text();
|
|
1133
1431
|
if (res.status === 409 && text.includes('already exists')) {
|
|
1134
|
-
logInfo(`${pc.green('✔')}
|
|
1432
|
+
logInfo(`${pc.green('✔')} ${label} already exists: ${displayAssetPath(asset.path)}`);
|
|
1135
1433
|
}
|
|
1136
1434
|
else {
|
|
1137
|
-
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)}`);
|
|
1138
1436
|
}
|
|
1139
1437
|
}
|
|
1140
1438
|
else {
|
|
1141
|
-
logInfo(
|
|
1439
|
+
logInfo(`${pc.green('✔')} ${label} uploaded: ${displayAssetPath(asset.path)}`);
|
|
1142
1440
|
}
|
|
1143
1441
|
}
|
|
1144
1442
|
catch (err) {
|
|
@@ -1148,7 +1446,7 @@ async function uploadAssets(assets, apiUrl, secret, recordingId, signal, throwIf
|
|
|
1148
1446
|
if (isUploadAssetError(err)) {
|
|
1149
1447
|
throw err;
|
|
1150
1448
|
}
|
|
1151
|
-
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)}`);
|
|
1152
1450
|
}
|
|
1153
1451
|
}
|
|
1154
1452
|
}
|
|
@@ -1168,6 +1466,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1168
1466
|
failedVideoNames: [],
|
|
1169
1467
|
failedVideoMessages: [],
|
|
1170
1468
|
studioNotices: [],
|
|
1469
|
+
elevenLabsKeyMissingVideos: [],
|
|
1470
|
+
notices: [],
|
|
1171
1471
|
plan: null,
|
|
1172
1472
|
};
|
|
1173
1473
|
}
|
|
@@ -1175,7 +1475,6 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1175
1475
|
entries = entries.filter((e) => e === specificEntry);
|
|
1176
1476
|
}
|
|
1177
1477
|
let firstProjectId = null;
|
|
1178
|
-
const elevenLabsApiKey = process.env.ELEVENLABS_API_KEY?.trim() || undefined;
|
|
1179
1478
|
try {
|
|
1180
1479
|
const candidates = (await Promise.all(entries.map(async (entry) => {
|
|
1181
1480
|
uploadAbort.throwIfAborted();
|
|
@@ -1189,6 +1488,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1189
1488
|
failedVideoNames: [],
|
|
1190
1489
|
failedVideoMessages: [],
|
|
1191
1490
|
studioNotices: [],
|
|
1491
|
+
elevenLabsKeyMissingVideos: [],
|
|
1492
|
+
notices: [],
|
|
1192
1493
|
plan: null,
|
|
1193
1494
|
};
|
|
1194
1495
|
}
|
|
@@ -1197,7 +1498,7 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1197
1498
|
// waits for all of them to land before dispatching the batch, so it needs
|
|
1198
1499
|
// to know how many to expect.
|
|
1199
1500
|
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,
|
|
1501
|
+
const results = await Promise.all(candidates.map(async (candidate, index) => await uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, verbose, uploadAbort, progressReporter, index, recordId, screenshotCount)));
|
|
1201
1502
|
firstProjectId =
|
|
1202
1503
|
results.find((result) => result.projectId !== null)?.projectId ?? null;
|
|
1203
1504
|
const resolvedPlan = results.find((result) => result.plan !== undefined)?.plan ?? null;
|
|
@@ -1217,6 +1518,10 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1217
1518
|
},
|
|
1218
1519
|
]
|
|
1219
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 : []);
|
|
1220
1525
|
return {
|
|
1221
1526
|
projectId: firstProjectId,
|
|
1222
1527
|
recordId,
|
|
@@ -1224,6 +1529,8 @@ export async function uploadRecordings(screenciDir, projectName, apiUrl, secret,
|
|
|
1224
1529
|
failedVideoNames,
|
|
1225
1530
|
failedVideoMessages,
|
|
1226
1531
|
studioNotices,
|
|
1532
|
+
elevenLabsKeyMissingVideos,
|
|
1533
|
+
notices,
|
|
1227
1534
|
plan: resolvedPlan,
|
|
1228
1535
|
};
|
|
1229
1536
|
}
|
|
@@ -1344,19 +1651,29 @@ function isMissingFileError(err) {
|
|
|
1344
1651
|
}
|
|
1345
1652
|
async function loadEnvFileFromConfigSource(resolvedConfigPath, warnOnFailure) {
|
|
1346
1653
|
try {
|
|
1347
|
-
const
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
: resolve(dirname(resolvedConfigPath), '.env'), warnOnFailure);
|
|
1654
|
+
const envFilePath = (await resolveConfiguredEnvFilePath(resolvedConfigPath)) ??
|
|
1655
|
+
resolve(dirname(resolvedConfigPath), '.env');
|
|
1656
|
+
loadEnvFile(envFilePath, warnOnFailure);
|
|
1351
1657
|
}
|
|
1352
1658
|
catch {
|
|
1353
1659
|
// Config import may require Playwright context or dynamic values. Continue with
|
|
1354
1660
|
// the existing process env; Playwright will still load the config normally.
|
|
1355
1661
|
}
|
|
1356
1662
|
}
|
|
1357
|
-
|
|
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) {
|
|
1358
1675
|
try {
|
|
1359
|
-
const screenciConfig = await
|
|
1676
|
+
const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
|
|
1360
1677
|
if (!screenciConfig.envFile)
|
|
1361
1678
|
return undefined;
|
|
1362
1679
|
return resolve(dirname(resolvedConfigPath), screenciConfig.envFile);
|
|
@@ -1493,7 +1810,7 @@ async function updateVideoVisibility(videoId, isPublic, configPath) {
|
|
|
1493
1810
|
});
|
|
1494
1811
|
if (!res.ok) {
|
|
1495
1812
|
const text = await res.text();
|
|
1496
|
-
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)}`);
|
|
1497
1814
|
}
|
|
1498
1815
|
logger.info(`${isPublic ? 'Made public' : 'Made private'}: ${videoId}`);
|
|
1499
1816
|
}
|
|
@@ -1535,7 +1852,7 @@ async function triggerRemoteRun(configPath, grep, languages) {
|
|
|
1535
1852
|
});
|
|
1536
1853
|
if (!res.ok) {
|
|
1537
1854
|
const text = await res.text();
|
|
1538
|
-
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)}`);
|
|
1539
1856
|
}
|
|
1540
1857
|
const filters = [
|
|
1541
1858
|
...(grep ? [`filter: ${grep}`] : []),
|
|
@@ -1596,7 +1913,7 @@ async function printInfo(configPath) {
|
|
|
1596
1913
|
});
|
|
1597
1914
|
if (!res.ok) {
|
|
1598
1915
|
const text = await res.text();
|
|
1599
|
-
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)}`);
|
|
1600
1917
|
}
|
|
1601
1918
|
const data = await res.json();
|
|
1602
1919
|
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
@@ -1863,6 +2180,144 @@ export async function runLogin(configPath) {
|
|
|
1863
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()}).`);
|
|
1864
2181
|
}
|
|
1865
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
|
+
}
|
|
1866
2321
|
export async function main() {
|
|
1867
2322
|
if (process.argv.length <= 2) {
|
|
1868
2323
|
logger.error('Error: No command provided');
|
|
@@ -1894,130 +2349,32 @@ export async function main() {
|
|
|
1894
2349
|
return;
|
|
1895
2350
|
}
|
|
1896
2351
|
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;
|
|
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) {
|
|
1918
2358
|
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".');
|
|
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);
|
|
1939
2366
|
}
|
|
1940
2367
|
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;
|
|
2368
|
+
throw new RecordFailureHintError(error);
|
|
2017
2369
|
}
|
|
2018
|
-
logger.warn('Failed to load config for upload:', err);
|
|
2019
2370
|
}
|
|
2020
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);
|
|
2021
2378
|
if (playwrightFailure !== null) {
|
|
2022
2379
|
throw playwrightFailure;
|
|
2023
2380
|
}
|
|
@@ -2245,7 +2602,7 @@ function validateArgs(args) {
|
|
|
2245
2602
|
}
|
|
2246
2603
|
/**
|
|
2247
2604
|
* Fetch the project's current Studio text-field overrides so they can be
|
|
2248
|
-
* injected as
|
|
2605
|
+
* injected as SCREENCI_VALUES_OVERRIDES before a recording. On-screen `values`
|
|
2249
2606
|
* fields render during the recording (they cannot be patched at render time),
|
|
2250
2607
|
* so any Studio edits must be present here; code-declared seeds are the
|
|
2251
2608
|
* fallback. Best-effort: any failure returns an empty env so the SDK uses the
|
|
@@ -2274,7 +2631,7 @@ async function fetchTextOverridesEnv(configPath, languages, verbose) {
|
|
|
2274
2631
|
Object.keys(overrides).length === 0) {
|
|
2275
2632
|
return {};
|
|
2276
2633
|
}
|
|
2277
|
-
return { [
|
|
2634
|
+
return { [SCREENCI_VALUES_OVERRIDES_ENV]: JSON.stringify(overrides) };
|
|
2278
2635
|
}
|
|
2279
2636
|
catch (error) {
|
|
2280
2637
|
if (verbose) {
|