screenci 0.0.76 → 0.0.79

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