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.
Files changed (184) hide show
  1. package/dist/cli.d.ts +51 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +557 -200
  4. package/dist/cli.js.map +1 -1
  5. package/dist/docs/manifest.d.ts +253 -98
  6. package/dist/docs/manifest.d.ts.map +1 -1
  7. package/dist/docs/manifest.js +92 -32
  8. package/dist/docs/manifest.js.map +1 -1
  9. package/dist/docs/video-sources/camera-and-zooming.screenci.d.ts +2 -0
  10. package/dist/docs/video-sources/camera-and-zooming.screenci.d.ts.map +1 -0
  11. package/dist/docs/video-sources/camera-and-zooming.screenci.js +57 -0
  12. package/dist/docs/video-sources/camera-and-zooming.screenci.js.map +1 -0
  13. package/dist/docs/video-sources/installation.screenci.js +14 -3
  14. package/dist/docs/video-sources/installation.screenci.js.map +1 -1
  15. package/dist/docs/video-sources/languages.screenci.d.ts +2 -0
  16. package/dist/docs/video-sources/languages.screenci.d.ts.map +1 -0
  17. package/dist/docs/video-sources/languages.screenci.js +68 -0
  18. package/dist/docs/video-sources/languages.screenci.js.map +1 -0
  19. package/dist/docs/video-sources/locator-highlight-animated.screenci.js +1 -1
  20. package/dist/docs/video-sources/locator-highlight-animated.screenci.js.map +1 -1
  21. package/dist/docs/video-sources/narration.screenci.d.ts +2 -0
  22. package/dist/docs/video-sources/narration.screenci.d.ts.map +1 -0
  23. package/dist/docs/video-sources/narration.screenci.js +71 -0
  24. package/dist/docs/video-sources/narration.screenci.js.map +1 -0
  25. package/dist/docs/video-sources/public-urls-and-embeds.screenci.d.ts +2 -0
  26. package/dist/docs/video-sources/public-urls-and-embeds.screenci.d.ts.map +1 -0
  27. package/dist/docs/video-sources/public-urls-and-embeds.screenci.js +60 -0
  28. package/dist/docs/video-sources/public-urls-and-embeds.screenci.js.map +1 -0
  29. package/dist/docs/video-sources/speaking-style.screenci.d.ts +2 -0
  30. package/dist/docs/video-sources/speaking-style.screenci.d.ts.map +1 -0
  31. package/dist/docs/video-sources/speaking-style.screenci.js +76 -0
  32. package/dist/docs/video-sources/speaking-style.screenci.js.map +1 -0
  33. package/dist/docs/video-sources/vertical-video.screenci.d.ts +2 -0
  34. package/dist/docs/video-sources/vertical-video.screenci.d.ts.map +1 -0
  35. package/dist/docs/video-sources/vertical-video.screenci.js +58 -0
  36. package/dist/docs/video-sources/vertical-video.screenci.js.map +1 -0
  37. package/dist/docs/videos.d.ts +20 -0
  38. package/dist/docs/videos.d.ts.map +1 -1
  39. package/dist/docs/videos.js +25 -0
  40. package/dist/docs/videos.js.map +1 -1
  41. package/dist/e2e/instrument.e2e.js +8 -0
  42. package/dist/e2e/instrument.e2e.js.map +1 -1
  43. package/dist/e2e/redact.e2e.d.ts +2 -0
  44. package/dist/e2e/redact.e2e.d.ts.map +1 -0
  45. package/dist/e2e/redact.e2e.js +161 -0
  46. package/dist/e2e/redact.e2e.js.map +1 -0
  47. package/dist/index.d.ts +10 -3
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +8 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/src/asset.d.ts +154 -13
  52. package/dist/src/asset.d.ts.map +1 -1
  53. package/dist/src/asset.js +262 -32
  54. package/dist/src/asset.js.map +1 -1
  55. package/dist/src/assetHash.d.ts +27 -0
  56. package/dist/src/assetHash.d.ts.map +1 -0
  57. package/dist/src/assetHash.js +0 -0
  58. package/dist/src/assetHash.js.map +1 -0
  59. package/dist/src/audio.d.ts +8 -5
  60. package/dist/src/audio.d.ts.map +1 -1
  61. package/dist/src/audio.js +56 -31
  62. package/dist/src/audio.js.map +1 -1
  63. package/dist/src/builder.d.ts +132 -32
  64. package/dist/src/builder.d.ts.map +1 -1
  65. package/dist/src/builder.js +66 -20
  66. package/dist/src/builder.js.map +1 -1
  67. package/dist/src/changeFocus.d.ts.map +1 -1
  68. package/dist/src/changeFocus.js +103 -34
  69. package/dist/src/changeFocus.js.map +1 -1
  70. package/dist/src/cue.d.ts +29 -17
  71. package/dist/src/cue.d.ts.map +1 -1
  72. package/dist/src/cue.js +138 -61
  73. package/dist/src/cue.js.map +1 -1
  74. package/dist/src/declare.d.ts +8 -4
  75. package/dist/src/declare.d.ts.map +1 -1
  76. package/dist/src/declare.js +28 -10
  77. package/dist/src/declare.js.map +1 -1
  78. package/dist/src/defaults.d.ts +23 -0
  79. package/dist/src/defaults.d.ts.map +1 -1
  80. package/dist/src/defaults.js +23 -0
  81. package/dist/src/defaults.js.map +1 -1
  82. package/dist/src/events.d.ts +289 -25
  83. package/dist/src/events.d.ts.map +1 -1
  84. package/dist/src/events.js +145 -13
  85. package/dist/src/events.js.map +1 -1
  86. package/dist/src/htmlRasterizer.d.ts +10 -0
  87. package/dist/src/htmlRasterizer.d.ts.map +1 -1
  88. package/dist/src/htmlRasterizer.js +12 -2
  89. package/dist/src/htmlRasterizer.js.map +1 -1
  90. package/dist/src/init.d.ts +2 -0
  91. package/dist/src/init.d.ts.map +1 -1
  92. package/dist/src/init.js +64 -18
  93. package/dist/src/init.js.map +1 -1
  94. package/dist/src/instrument.d.ts.map +1 -1
  95. package/dist/src/instrument.js +25 -7
  96. package/dist/src/instrument.js.map +1 -1
  97. package/dist/src/locales.d.ts.map +1 -1
  98. package/dist/src/locales.js +1 -0
  99. package/dist/src/locales.js.map +1 -1
  100. package/dist/src/localize.d.ts +25 -17
  101. package/dist/src/localize.d.ts.map +1 -1
  102. package/dist/src/localize.js +18 -10
  103. package/dist/src/localize.js.map +1 -1
  104. package/dist/src/localizeRuntime.d.ts +18 -13
  105. package/dist/src/localizeRuntime.d.ts.map +1 -1
  106. package/dist/src/localizeRuntime.js +28 -18
  107. package/dist/src/localizeRuntime.js.map +1 -1
  108. package/dist/src/logger.d.ts +5 -0
  109. package/dist/src/logger.d.ts.map +1 -1
  110. package/dist/src/logger.js +7 -0
  111. package/dist/src/logger.js.map +1 -1
  112. package/dist/src/narrationVisibility.d.ts +19 -0
  113. package/dist/src/narrationVisibility.d.ts.map +1 -0
  114. package/dist/src/narrationVisibility.js +26 -0
  115. package/dist/src/narrationVisibility.js.map +1 -0
  116. package/dist/src/recordingData.d.ts +7 -0
  117. package/dist/src/recordingData.d.ts.map +1 -1
  118. package/dist/src/recordingSize.d.ts +32 -0
  119. package/dist/src/recordingSize.d.ts.map +1 -0
  120. package/dist/src/recordingSize.js +54 -0
  121. package/dist/src/recordingSize.js.map +1 -0
  122. package/dist/src/redact.d.ts +68 -0
  123. package/dist/src/redact.d.ts.map +1 -0
  124. package/dist/src/redact.js +138 -0
  125. package/dist/src/redact.js.map +1 -0
  126. package/dist/src/redactController.d.ts +61 -0
  127. package/dist/src/redactController.d.ts.map +1 -0
  128. package/dist/src/redactController.js +232 -0
  129. package/dist/src/redactController.js.map +1 -0
  130. package/dist/src/runtimeContext.d.ts +32 -0
  131. package/dist/src/runtimeContext.d.ts.map +1 -1
  132. package/dist/src/runtimeContext.js +21 -0
  133. package/dist/src/runtimeContext.js.map +1 -1
  134. package/dist/src/runtimeMode.d.ts +15 -7
  135. package/dist/src/runtimeMode.d.ts.map +1 -1
  136. package/dist/src/runtimeMode.js +17 -6
  137. package/dist/src/runtimeMode.js.map +1 -1
  138. package/dist/src/screenAudio.d.ts +34 -4
  139. package/dist/src/screenAudio.d.ts.map +1 -1
  140. package/dist/src/screenAudio.js +0 -0
  141. package/dist/src/screenAudio.js.map +1 -1
  142. package/dist/src/screenAudioSink.d.ts +12 -0
  143. package/dist/src/screenAudioSink.d.ts.map +1 -1
  144. package/dist/src/screenAudioSink.js +30 -0
  145. package/dist/src/screenAudioSink.js.map +1 -1
  146. package/dist/src/screenshot.d.ts +16 -14
  147. package/dist/src/screenshot.d.ts.map +1 -1
  148. package/dist/src/screenshot.js +11 -11
  149. package/dist/src/screenshot.js.map +1 -1
  150. package/dist/src/sourceTrim.d.ts +29 -0
  151. package/dist/src/sourceTrim.d.ts.map +1 -0
  152. package/dist/src/sourceTrim.js +76 -0
  153. package/dist/src/sourceTrim.js.map +1 -0
  154. package/dist/src/studio.d.ts +71 -3
  155. package/dist/src/studio.d.ts.map +1 -1
  156. package/dist/src/studio.js +19 -1
  157. package/dist/src/studio.js.map +1 -1
  158. package/dist/src/timelineOffset.d.ts +46 -0
  159. package/dist/src/timelineOffset.d.ts.map +1 -0
  160. package/dist/src/timelineOffset.js +66 -0
  161. package/dist/src/timelineOffset.js.map +1 -0
  162. package/dist/src/titleValidation.d.ts +38 -0
  163. package/dist/src/titleValidation.d.ts.map +1 -1
  164. package/dist/src/titleValidation.js +42 -0
  165. package/dist/src/titleValidation.js.map +1 -1
  166. package/dist/src/types.d.ts +85 -0
  167. package/dist/src/types.d.ts.map +1 -1
  168. package/dist/src/types.js +1 -1
  169. package/dist/src/types.js.map +1 -1
  170. package/dist/src/video.d.ts +37 -34
  171. package/dist/src/video.d.ts.map +1 -1
  172. package/dist/src/video.js +99 -48
  173. package/dist/src/video.js.map +1 -1
  174. package/dist/src/voiceSampleAudio.d.ts +34 -0
  175. package/dist/src/voiceSampleAudio.d.ts.map +1 -0
  176. package/dist/src/voiceSampleAudio.js +105 -0
  177. package/dist/src/voiceSampleAudio.js.map +1 -0
  178. package/dist/src/voices.d.ts +2 -2
  179. package/dist/src/voices.d.ts.map +1 -1
  180. package/dist/src/voices.js +8 -3
  181. package/dist/src/voices.js.map +1 -1
  182. package/dist/tsconfig.tsbuildinfo +1 -1
  183. package/package.json +3 -2
  184. 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, SCREENCI_TEXT_OVERRIDES_ENV, SCREENCI_RECORD_OPTIONS_ENV, } from './src/runtimeMode.js';
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: annotateRecordingDataWithAssetHashes(data, preparedUploadAssets),
370
+ data,
356
371
  preparedUploadAssets,
357
372
  };
358
373
  }
359
- async function uploadRecordingCandidate(candidate, screenciDir, projectName, apiUrl, secret, elevenLabsApiKey, verbose, uploadAbort, progressReporter, progressIndex, recordId, expectedScreenshotCount) {
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 = data.output === 'screenshot';
384
+ const isScreenshot = rawData.output === 'screenshot';
370
385
  const recordingFileName = isScreenshot
371
- ? (data.screenshot?.path ?? 'screenshot.png')
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 (!existingHash) {
830
- throw new Error(`Custom voice file not found and no cached assetHash available: ${voicePath}`);
831
- }
832
- logger.warn(`Custom voice file not found locally, assuming previously uploaded recording asset is valid: ${voicePath}`);
833
- for (const ref of refs) {
834
- ref.assetHash = existingHash;
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
- fileHash: existingHash,
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
- const assetHash = createHash('sha256').update(fileBuffer).digest('hex');
846
- const contentType = contentTypeForPath(resolvedPath);
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: fileBuffer.byteLength,
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
- logger.warn(`Asset file not found, skipping upload: ${event.path}`);
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
- if (!event.fileHash || assets.has(`hash:${event.fileHash}`))
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
- logger.warn(`Audio file not found, skipping upload: ${event.path}`);
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(`hash:${event.fileHash}`, {
899
- fileHash: event.fileHash,
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
- fileBuffer: resolvedFile.buffer,
921
- contentType: contentTypeForPath(resolvedFile.resolvedPath),
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 === 'object' &&
929
- translation !== null &&
930
- 'assetHash' in translation &&
931
- typeof translation.assetHash === 'string' &&
932
- !assets.has(`hash:${translation.assetHash}`)) {
933
- const resolvedFile = 'assetPath' in translation &&
934
- typeof translation.assetPath === 'string'
935
- ? await readRecordingFile(translation.assetPath, configDir, sourceFilePath)
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:${translation.assetHash}`, {
938
- fileHash: translation.assetHash,
939
- path: translation.assetPath ??
940
- translation.assetHash,
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
- fileBuffer: resolvedFile.buffer,
944
- contentType: contentTypeForPath(resolvedFile.resolvedPath),
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
- return { assetHash: voice.assetHash };
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
- const fileHash = byName.get(event.name);
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 hash was already computed during recording
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 { assetPath: _removed, ...rest } = translation;
998
- return [language, rest];
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: strip assetPath, keep assetHash
1016
- if (typeof event.assetHash === 'string') {
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(`${pc.green('✔')} Asset already exists: ${displayAssetPath(asset.path)}`);
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
- throw new UploadAssetError(`Asset bytes not available for upload and backend does not have it yet: ${asset.path}`);
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('✔')} Asset already exists: ${displayAssetPath(asset.path)}`);
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(`Asset uploaded: ${displayAssetPath(asset.path)}`);
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, elevenLabsApiKey, verbose, uploadAbort, progressReporter, index, recordId, screenshotCount)));
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 screenciConfig = await tryReadConfigFromSource(resolvedConfigPath);
1348
- loadEnvFile(screenciConfig.envFile
1349
- ? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
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
- async function resolveConfiguredEnvFilePath(resolvedConfigPath) {
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 tryReadConfigFromSource(resolvedConfigPath);
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
- try {
1898
- await run('record', parsed.otherArgs, parsed.configPath, parsed.verbose, false, parsed.pollAuth, parsed.languages, parsed.noPollAuth);
1899
- }
1900
- catch (error) {
1901
- if (!(error instanceof Error))
1902
- throw error;
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
- const screenciConfig = await loadRecordConfigWithoutPlaywrightCollision(resolvedConfigPath);
1920
- loadEnvFile(screenciConfig.envFile
1921
- ? resolve(dirname(resolvedConfigPath), screenciConfig.envFile)
1922
- : resolve(dirname(resolvedConfigPath), '.env'), true);
1923
- const apiUrl = getDevBackendUrl();
1924
- const appUrl = getDevFrontendUrl();
1925
- const secret = process.env.SCREENCI_SECRET;
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
- if (playwrightFailure !== null && uploadPolicy === 'passed-only') {
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 SCREENCI_TEXT_OVERRIDES before a recording. On-screen `text`
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 { [SCREENCI_TEXT_OVERRIDES_ENV]: JSON.stringify(overrides) };
2634
+ return { [SCREENCI_VALUES_OVERRIDES_ENV]: JSON.stringify(overrides) };
2278
2635
  }
2279
2636
  catch (error) {
2280
2637
  if (verbose) {