screenci 0.0.75 → 0.0.78

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