@hyperframes/share 0.9.27 → 0.9.28-alpha.1

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 (54) hide show
  1. package/README.md +58 -1
  2. package/dist/index.d.mts +1 -0
  3. package/dist/projectLock.d.mts +2 -2
  4. package/dist/projectSnapshot.d.mts +3 -1
  5. package/dist/reviewExport.d.mts +11 -1
  6. package/dist/reviewUpload.d.mts +2 -0
  7. package/dist/shareSources.d.mts +24 -0
  8. package/dist/sourceIgnore.d.mts +3 -0
  9. package/dist/sources/adapterTypes.d.ts +39 -0
  10. package/dist/sources/api.d.mts +29 -0
  11. package/dist/sources/capture.d.mts +24 -0
  12. package/dist/sources/contract.d.mts +2 -0
  13. package/dist/sources/errors.d.mts +24 -0
  14. package/dist/sources/limits.d.mts +12 -0
  15. package/dist/sources/manifest.d.mts +23 -0
  16. package/dist/sources/pathPolicy.d.mts +25 -0
  17. package/dist/sources/projectState.d.mts +9 -0
  18. package/dist/sources/store.d.mts +3 -0
  19. package/dist/sources/transfer.d.mts +45 -0
  20. package/dist/sources/types.d.ts +353 -0
  21. package/dist/sources/unicode.d.mts +15 -0
  22. package/dist/sources/upload.d.mts +9 -0
  23. package/dist/sources/uploadFiles.d.mts +31 -0
  24. package/dist/sources/wire.d.mts +21 -0
  25. package/package.json +1 -1
  26. package/scripts/copy-types.mjs +3 -0
  27. package/src/index.mjs +6 -0
  28. package/src/projectLock.mjs +9 -2
  29. package/src/projectSnapshot.mjs +22 -6
  30. package/src/reviewExport.mjs +156 -41
  31. package/src/reviewUpload.mjs +18 -16
  32. package/src/reviewVersionSentences.mjs +2 -1
  33. package/src/shareSources.mjs +236 -0
  34. package/src/sourceIgnore.mjs +69 -0
  35. package/src/sources/adapterTypes.d.ts +39 -0
  36. package/src/sources/api.mjs +114 -0
  37. package/src/sources/capture.mjs +264 -0
  38. package/src/sources/contract/LICENSE-Unicode.txt +39 -0
  39. package/src/sources/contract/path-policy.json +311 -0
  40. package/src/sources/contract/unicode-15.1.0.json +6179 -0
  41. package/src/sources/contract.mjs +13 -0
  42. package/src/sources/errors.mjs +77 -0
  43. package/src/sources/limits.mjs +48 -0
  44. package/src/sources/manifest.mjs +154 -0
  45. package/src/sources/pathPolicy.mjs +197 -0
  46. package/src/sources/projectState.mjs +59 -0
  47. package/src/sources/store.mjs +82 -0
  48. package/src/sources/transfer.mjs +196 -0
  49. package/src/sources/types.d.ts +353 -0
  50. package/src/sources/unicode.mjs +163 -0
  51. package/src/sources/upload.mjs +201 -0
  52. package/src/sources/uploadFiles.mjs +263 -0
  53. package/src/sources/wire.mjs +205 -0
  54. package/LICENSE +0 -190
@@ -39,9 +39,10 @@ import { versionSentence } from "./reviewVersionSentences.mjs";
39
39
  * definitive result; `reviewSent`: cleanup cutoff, with attribution only when the accepted attempt is
40
40
  * known; `last`: the newest version.
41
41
  * @typedef {{ pending: { body: ExportRequest, videoId: string | null, projectId?: string | null,
42
- * checkingSince?: number, snapshotRefused?: boolean, unanswered?: boolean, reviewSent?: ReviewSent } | null,
43
- * last: { fingerprint: string, versionId: string, exportedAt: string, projectId: string, files?: boolean, checksum?: string }
44
- * | null }} VersionRecord */
42
+ * renderKey?: string, checkingSince?: number, snapshotRefused?: boolean, unanswered?: boolean, reviewSent?: ReviewSent } | null,
43
+ * last: { fingerprint: string, versionId: string, exportedAt: string, projectId: string, files?: boolean,
44
+ * snapshot?: ExportRequest["snapshot"], checksum?: string }
45
+ * | null, failure?: (Failure & { projectId: string }) | null }} VersionRecord */
45
46
 
46
47
  /** `onAdded`: a version went ready, with whether it carries files and whether it is the page's first from here.
47
48
  * @typedef {{ onProgress?: (progress: ReviewExportProgress | null) => void, onUploaded?: () => Promise<void>,
@@ -49,10 +50,11 @@ import { versionSentence } from "./reviewVersionSentences.mjs";
49
50
 
50
51
  /** `projectId` overrides the recorded review project; `open`: a held video is watched only while the project is open.
51
52
  * `filesForExport`: the synced snapshot a new version carries, null for
52
- * none.
53
+ * none. `allowPending` lets a host treat accepted processing or screening as a successful return.
53
54
  * @typedef {VersionHooks & { projectPath: string, title: string, client: ReviewClient, put?: typeof putFile,
54
55
  * sleep?: (ms: number) => Promise<void>, probe?: (file: string) => Promise<VideoFacts | null>, now?: () => number,
55
- * projectId?: string | null, filesForExport?: FilesForRender, fromReview?: FromReview | null }} VersionRequest */
56
+ * projectId?: string | null, filesForExport?: FilesForRender, fromReview?: FromReview | null,
57
+ * allowPending?: boolean, requireSource?: boolean, omitSource?: boolean }} VersionRequest */
56
58
 
57
59
  /** The snapshot of `rendered`, the project as the export was rendered; null when the folder is not that render.
58
60
  * @typedef {(projectPath: string, rendered: string | null) => Promise<import("./types/reviewWire.js").SnapshotRef | null>}
@@ -177,18 +179,36 @@ function isLast(value) {
177
179
  isSavedId(value.versionId) &&
178
180
  isSavedId(value.exportedAt) &&
179
181
  isSavedId(value.projectId) &&
182
+ isLastMetadata(value)
183
+ );
184
+ }
185
+
186
+ /** @param {Record<string, unknown>} value */
187
+ function isLastMetadata(value) {
188
+ return (
180
189
  isOptionalBoolean(value.files) &&
190
+ isOptionalSnapshot(value.snapshot) &&
181
191
  (value.checksum === undefined || isSavedId(value.checksum))
182
192
  );
183
193
  }
184
194
 
185
195
  /** @param {Record<string, unknown>} saved
186
- * @returns {saved is {pending?: VersionRecord["pending"], last?: SavedLast | null}} */
196
+ * @returns {saved is {pending?: VersionRecord["pending"], last?: SavedLast | null, failure?: unknown}} */
187
197
  const isExportState = (saved) =>
188
198
  ("pending" in saved || "last" in saved) &&
189
199
  (saved.pending == null || isPending(saved.pending)) &&
190
200
  (saved.last == null || isLast(saved.last));
191
201
 
202
+ /** @param {unknown} value @returns {value is Failure & { projectId: string }} */
203
+ const isSavedFailure = (value) =>
204
+ isJsonObject(value) &&
205
+ isSavedId(value.projectId) &&
206
+ typeof value.exportKey === "string" &&
207
+ typeof value.sentence === "string" &&
208
+ typeof value.final === "boolean" &&
209
+ typeof value.exportHelps === "boolean" &&
210
+ isOptionalBoolean(value.held);
211
+
192
212
  /** @param {string} dir @returns {Promise<VersionRecord>} */
193
213
  async function readState(dir) {
194
214
  const file = statePath(dir);
@@ -198,6 +218,7 @@ async function readState(dir) {
198
218
  const { last, pending } = saved;
199
219
  return {
200
220
  pending: pending ?? null,
221
+ failure: isSavedFailure(saved.failure) ? saved.failure : null,
201
222
  last: typeof last?.fingerprint === "string" ? { ...last, fingerprint: last.fingerprint } : null,
202
223
  };
203
224
  }
@@ -234,7 +255,7 @@ const failures = new Map();
234
255
  /** Which export a failure was about: the newest export file and the project as it was rendered. @param {string} dir */
235
256
  const exportKeyOf = (dir) => {
236
257
  const latest = latestExportFile(dir);
237
- return latest ? `${latest.path}\n${latest.fingerprint ?? ""}` : "";
258
+ return latest ? `${latest.path}\n${latest.fingerprint ?? ""}\n${latest.id}` : "";
238
259
  };
239
260
 
240
261
  /** Whether the newest export can go up now: there is one, it is MP4, and the project has not moved past it.
@@ -259,7 +280,8 @@ export async function reviewVersion(projectPath, server = {}) {
259
280
  const last = state.last?.projectId === projectId ? state.last : null;
260
281
  const onServer =
261
282
  server.latestReadyVersionId === undefined || server.latestReadyVersionId === last?.versionId;
262
- const failed = failures.get(key);
283
+ const savedFailure = state.failure?.projectId === projectId ? state.failure : null;
284
+ const failed = savedFailure ?? failures.get(key);
263
285
  return {
264
286
  last: last
265
287
  ? { exportedAt: last.exportedAt, current: last.fingerprint === now && onServer }
@@ -279,8 +301,8 @@ export async function reviewVersion(projectPath, server = {}) {
279
301
 
280
302
  /** The newest MP4 and the body that adds it; a stale, non-MP4, too big or too long export never sends anything.
281
303
  * @param {string} dir @param {string} title @param {(file: string) => Promise<VideoFacts | null>} probe
282
- * @param {FilesForRender} filesForExport */
283
- async function exportSource(dir, title, probe, filesForExport) {
304
+ * @param {FilesForRender} filesForExport @param {boolean} requireSource */
305
+ async function exportSource(dir, title, probe, filesForExport, requireSource) {
284
306
  const latest = latestExportFile(dir);
285
307
  if (!latest || sourceOf(dir, await projectFingerprint(dir)) !== "ready")
286
308
  return { failure: local("Export an MP4 of the project as it is now first.") };
@@ -307,18 +329,34 @@ async function exportSource(dir, title, probe, filesForExport) {
307
329
  content_type: "video/mp4",
308
330
  },
309
331
  };
310
- const snapshot = await filesForExport(dir, body.source_fingerprint).catch((error) => {
311
- // expected: the version still goes up, and Share's files line says it has no files and offers Sync now.
312
- console.error(
313
- "[review files] couldn't read the synced snapshot; the version goes up without files:",
314
- error,
315
- );
316
- return null;
317
- });
332
+ const snapshot = await sourceSnapshot(
333
+ dir,
334
+ body.source_fingerprint,
335
+ filesForExport,
336
+ requireSource,
337
+ );
338
+ if (missingRequiredSource(requireSource, snapshot))
339
+ return {
340
+ failure: local(
341
+ "Source changed after preparation. Retry sharing, or choose video-only explicitly.",
342
+ ),
343
+ };
318
344
  if (snapshot) body.snapshot = snapshot;
319
345
  return { file: latest.path, body };
320
346
  }
321
347
 
348
+ /** @param {boolean} required @param {import("./types/reviewWire.js").SnapshotRef | null} snapshot */
349
+ const missingRequiredSource = (required, snapshot) => required && !snapshot;
350
+
351
+ /** @param {string} dir @param {string | null} rendered @param {FilesForRender} filesForExport @param {boolean} requireSource */
352
+ async function sourceSnapshot(dir, rendered, filesForExport, requireSource) {
353
+ return filesForExport(dir, rendered).catch((error) => {
354
+ if (requireSource) throw error;
355
+ console.error("[review files] could not read source; adding video only:", error);
356
+ return null;
357
+ });
358
+ }
359
+
322
360
  /** @param {ExportRequest["snapshot"]} a @param {ExportRequest["snapshot"]} b */
323
361
  const sameSnapshot = (a, b) =>
324
362
  a?.space_id === b?.space_id &&
@@ -368,7 +406,7 @@ async function createdExport(r, id, pending, kept) {
368
406
  return await sendCreate(r, id, pending, kept);
369
407
  } catch (error) {
370
408
  const refused = error instanceof ReviewFailed && error.reason === "snapshot_mismatch";
371
- if (!refused || !pending.body.snapshot || pending.unanswered) throw error;
409
+ if (!refused || !pending.body.snapshot || pending.unanswered || r.requireSource) throw error;
372
410
  console.error("[review] snapshot refused; the version goes up without files:", error);
373
411
  const body = { ...pending.body, client_operation_id: `export-${randomUUID()}` };
374
412
  delete body.snapshot;
@@ -564,17 +602,31 @@ const spent = (error, pending) =>
564
602
  * @param {string} id @param {ExportDTO} done @param {FromReview | null} fromReview @returns {Promise<Said | null>} */
565
603
  async function finish(dir, state, pending, id, done, fromReview) {
566
604
  const ready = done.status === "ready";
567
- const last = {
568
- fingerprint: pending.body.source_fingerprint ?? "",
569
- versionId: done.version_id,
570
- exportedAt: done.exported_at,
571
- projectId: id,
572
- files: Boolean(pending.body.snapshot),
573
- checksum: pending.body.local_upload.checksum_sha256,
574
- };
575
- await writeState(dir, { pending: null, last: ready ? last : state.last });
605
+ const last = readyRecord(pending, id, done);
606
+ const failure = ready ? null : said(new ReviewFailed(200, done.error?.reason ?? ""));
607
+ await writeState(dir, {
608
+ pending: null,
609
+ last: ready ? last : state.last,
610
+ ...(failure && {
611
+ failure: { ...failure, projectId: id, exportKey: await failedExportKey(dir, pending) },
612
+ }),
613
+ });
576
614
  if (ready) await sharedFromReview(fromReview, dir, pending.reviewSent?.since);
577
- return ready ? null : said(new ReviewFailed(200, done.error?.reason ?? ""));
615
+ return failure;
616
+ }
617
+
618
+ /** @param {string} dir @param {NonNullable<VersionRecord["pending"]>} pending */
619
+ async function failedExportKey(dir, pending) {
620
+ if (typeof pending.renderKey === "string") return pending.renderKey;
621
+ const latest = latestExportFile(dir);
622
+ if (!latest) return "";
623
+ const checksum =
624
+ latest.fingerprint === pending.body.source_fingerprint
625
+ ? await fileSha256Base64(latest.path).catch(() => null)
626
+ : null;
627
+ return checksum === pending.body.local_upload.checksum_sha256
628
+ ? exportKeyOf(dir)
629
+ : `pending:${pending.body.client_operation_id}`;
578
630
  }
579
631
 
580
632
  /** @param {Required<VersionRequest>} r @param {(progress: ReviewExportProgress) => void} report
@@ -582,17 +634,37 @@ async function finish(dir, state, pending, id, done, fromReview) {
582
634
  async function run(r, report) {
583
635
  report({ step: "preparing", sent: 0, total: 0 });
584
636
  const state = await readState(r.projectPath);
585
- const source = await exportSource(r.projectPath, r.title, r.probe, r.filesForExport);
637
+ const source = await exportSource(
638
+ r.projectPath,
639
+ r.title,
640
+ r.probe,
641
+ r.omitSource ? noSource : r.filesForExport,
642
+ r.requireSource,
643
+ );
586
644
  if ("failure" in source) return source.failure ?? null;
587
645
  const id = r.projectId ?? (await reviewProjectId(r.projectPath));
588
646
  if (!id) return local("Share the project first, then add a version.");
589
- if (await alreadyUploaded(r.client, id, state, source.body)) return null;
647
+ if (
648
+ sameSourceMode(r, state.last, source.body.snapshot) &&
649
+ (await alreadyUploaded(r.client, id, state, source.body))
650
+ )
651
+ return null;
590
652
  return sendVersion(r, report, source, id, state);
591
653
  }
654
+ const noSource = async () => null;
655
+
656
+ /** @param {Required<VersionRequest>} r @param {VersionRecord["last"]} last @param {ExportRequest["snapshot"]} snapshot */
657
+ function sameSourceMode(r, last, snapshot) {
658
+ if (r.requireSource) return sameSnapshot(last?.snapshot, snapshot);
659
+ if (r.omitSource) return last?.files !== true;
660
+ return true;
661
+ }
662
+
592
663
  /** @param {ReviewClient} client @param {string} id @param {VersionRecord} state @param {ExportRequest} body */
593
664
  async function alreadyUploaded(client, id, state, body) {
594
665
  const last = state.last;
595
- if (state.pending || !last || last.projectId !== id) return false;
666
+ if (state.pending || state.failure?.projectId === id || !last || last.projectId !== id)
667
+ return false;
596
668
  if (
597
669
  last.fingerprint !== body.source_fingerprint ||
598
670
  last.checksum !== body.local_upload.checksum_sha256
@@ -604,8 +676,10 @@ async function alreadyUploaded(client, id, state, body) {
604
676
  * @param {{file: string, body: ExportRequest}} source @param {string} id @param {VersionRecord} state */
605
677
  async function sendVersion(r, report, source, id, state) {
606
678
  const first = state.last?.projectId !== id;
607
- let pending = bodyToSend(state, source.body, id);
608
- await writeState(r.projectPath, (state = { ...state, pending }));
679
+ let pending = bindRenderKey(bodyToSend(state, source.body, id), r.projectPath);
680
+ const sourceConflict = pendingSourceConflict(r, pending, source.body);
681
+ if (sourceConflict) return local(sourceConflict);
682
+ await writeState(r.projectPath, (state = { ...state, pending, failure: null }));
609
683
  try {
610
684
  const answer = await createdExport(r, id, pending, (next) =>
611
685
  writeState(r.projectPath, (state = { ...state, pending: next })),
@@ -631,16 +705,35 @@ async function sendVersion(r, report, source, id, state) {
631
705
  const done = await watch(r, id, uploaded, size, report, clock);
632
706
  return finishRun(r, state, pending, id, done, first);
633
707
  } catch (error) {
634
- if (spent(error, state.pending)) await writeState(r.projectPath, { ...state, pending: null });
635
- console.error("[review] version failed:", error);
636
- return said(error);
708
+ const failure = said(error);
709
+ await writeState(r.projectPath, {
710
+ ...state,
711
+ pending: spent(error, state.pending) ? null : state.pending,
712
+ failure: {
713
+ ...failure,
714
+ projectId: id,
715
+ exportKey: await failedExportKey(r.projectPath, pending),
716
+ },
717
+ });
718
+ return failure;
637
719
  }
638
720
  }
639
721
 
722
+ /** @param {Required<VersionRequest>} r @param {NonNullable<VersionRecord["pending"]>} pending @param {ExportRequest} fresh */
723
+ function pendingSourceConflict(r, pending, fresh) {
724
+ if (r.requireSource && !pending.body.snapshot)
725
+ return "A pending video-only upload must finish before attaching source. Retry with --video-only, then share source again.";
726
+ if (r.omitSource && pending.body.snapshot)
727
+ return "A pending upload includes source. Check its status or retry with source before creating a video-only version.";
728
+ if (r.requireSource && !sameSnapshot(pending.body.snapshot, fresh.snapshot))
729
+ return "A pending upload includes a different source snapshot. Check its status before sharing again.";
730
+ return null;
731
+ }
732
+
640
733
  /** @param {Required<VersionRequest>} r @param {VersionRecord} state
641
734
  * @param {NonNullable<VersionRecord["pending"]>} pending @param {string} id @param {ExportDTO} done @param {boolean} first */
642
735
  async function finishRun(r, state, pending, id, done, first) {
643
- if (going(done)) return stillGoing(done);
736
+ if (going(done)) return r.allowPending ? null : stillGoing(done);
644
737
  const failed = await finish(r.projectPath, state, pending, id, done, r.fromReview);
645
738
  if (!failed) {
646
739
  const fromReview = pending.reviewSent?.value;
@@ -663,8 +756,11 @@ export async function addVersion(request) {
663
756
  now: () => Date.now(),
664
757
  onProgress: () => {},
665
758
  open: () => true,
666
- filesForExport: async () => null,
759
+ allowPending: false,
760
+ filesForExport: noSource,
667
761
  fromReview: null,
762
+ requireSource: false,
763
+ omitSource: false,
668
764
  onAdded: () => {},
669
765
  ...request,
670
766
  projectId: request.projectId ?? null,
@@ -685,7 +781,6 @@ export async function addVersion(request) {
685
781
  if (kept) failures.set(key, { ...failure, exportKey: exportKeyOf(r.projectPath) });
686
782
  return failure?.sentence ?? null;
687
783
  } catch (error) {
688
- console.error("[review] version failed:", error);
689
784
  const sentence =
690
785
  error instanceof SharingStateError ? error.message : "Couldn't add this version. Try again.";
691
786
  failures.set(key, {
@@ -755,7 +850,7 @@ async function settleSavedExport(dir, client, fromReview) {
755
850
  const id = await reviewProjectId(dir);
756
851
  if (!pending?.videoId || !id || pending.projectId !== id) return;
757
852
  const done = await client.readExport(id, pending.videoId);
758
- if (going(done)) return;
853
+ if (done.status === "upload_pending" || going(done)) return;
759
854
  const failure = await finish(dir, state, pending, id, done, fromReview);
760
855
  const key = normalize(dir);
761
856
  if (failure) failures.set(key, { ...failure, exportKey: exportKeyOf(dir) });
@@ -825,3 +920,23 @@ export async function pendingReviewExport(projectPath) {
825
920
  checksum: pending.body.local_upload.checksum_sha256,
826
921
  };
827
922
  }
923
+
924
+ /** @param {NonNullable<VersionRecord["pending"]>} pending @param {string} id @param {ExportDTO} done */
925
+ function readyRecord(pending, id, done) {
926
+ return {
927
+ fingerprint: pending.body.source_fingerprint ?? "",
928
+ versionId: done.version_id,
929
+ exportedAt: done.exported_at,
930
+ projectId: id,
931
+ files: Boolean(pending.body.snapshot),
932
+ ...(pending.body.snapshot && { snapshot: pending.body.snapshot }),
933
+ checksum: pending.body.local_upload.checksum_sha256,
934
+ };
935
+ }
936
+
937
+ /** @param {NonNullable<VersionRecord["pending"]>} pending @param {string} dir */
938
+ function bindRenderKey(pending, dir) {
939
+ return typeof pending.renderKey === "string"
940
+ ? pending
941
+ : { ...pending, renderKey: exportKeyOf(dir) };
942
+ }
@@ -18,6 +18,22 @@ export function fileSha256Base64(file) {
18
18
 
19
19
  /** @typedef {"sent" | "expired" | "refused" | "offline"} PutOutcome */
20
20
 
21
+ /** @param {string} url */
22
+ export function trustedUploadUrl(url) {
23
+ if (!URL.canParse(url)) return null;
24
+ const target = new URL(url);
25
+ const loopback = target.protocol === "http:" && target.hostname === "127.0.0.1";
26
+ if (
27
+ (target.protocol !== "https:" && !loopback) ||
28
+ target.username ||
29
+ target.password ||
30
+ target.hash ||
31
+ !reviewUploadOrigins().includes(target.origin)
32
+ )
33
+ return null;
34
+ return target;
35
+ }
36
+
21
37
  /** Streams the file to the signed URL through main's fetch (the app's proxy), the server's headers as given plus
22
38
  * the length, and no bearer. Nothing sent or answered for `stallMs` ends it as offline, never "Uploading" forever.
23
39
  * @param {{ put_url: string, headers: Record<string, string> }} upload @param {string} file @param {number} size
@@ -29,22 +45,8 @@ export async function putFile(
29
45
  onSent,
30
46
  { stallMs = 60_000, fetchImpl = fetch } = {},
31
47
  ) {
32
- let target;
33
- try {
34
- target = new URL(upload.put_url);
35
- } catch {
36
- return "refused";
37
- }
38
- const loopback = target.protocol === "http:" && target.hostname === "127.0.0.1";
39
- if (
40
- (target.protocol !== "https:" && !loopback) ||
41
- target.username ||
42
- target.password ||
43
- target.hash ||
44
- !reviewUploadOrigins().includes(target.origin)
45
- ) {
46
- return "refused";
47
- }
48
+ const target = trustedUploadUrl(upload.put_url);
49
+ if (!target) return "refused";
48
50
  const stall = new AbortController();
49
51
  /** @type {ReturnType<typeof setTimeout> | undefined} */
50
52
  let timer;
@@ -14,7 +14,8 @@ const SAID = {
14
14
  export_daily_limit: (error) =>
15
15
  `You've added the most versions for today. Try again ${laterIn(error.retryAfterMs)}.`,
16
16
  content_rejected: "This video can't be shared.",
17
- export_blocked: "Adding videos to review pages isn't available for this account.",
17
+ export_blocked:
18
+ "Sharing new videos is blocked because of recent content moderation strikes in this workspace. Rendering again will not lift the block. For appeal instructions, visit https://help.heygen.com/en/articles/7842567-the-ethical-use-of-synthetic-media",
18
19
  feature_unavailable: "Review pages aren't available for your account yet.",
19
20
  permission_denied: "This account can't add videos to this review page yet.",
20
21
  upload_expired: "The upload took too long. Try again.",
@@ -0,0 +1,236 @@
1
+ // @ts-check
2
+ import { realpath } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { basename } from "node:path";
5
+ import { projectFingerprint } from "./projectSnapshot.mjs";
6
+ import { withProjectShareLock } from "./projectLock.mjs";
7
+ import { captureProject } from "./sources/capture.mjs";
8
+ import { sourceApi } from "./sources/api.mjs";
9
+ import { createProjectShareSourceStore } from "./sources/store.mjs";
10
+ import { realWait } from "./sources/transfer.mjs";
11
+ import { recoverSourceUpload, uploadSource } from "./sources/upload.mjs";
12
+
13
+ /** @typedef {import("./sources/adapterTypes.js").SourceResult} SourceResult
14
+ * @typedef {import("./sources/types.js").Binding} Binding */
15
+ /** @param {string} reason @returns {SourceResult} */
16
+ const unavailable = (reason) => ({ at: "unavailable", reason });
17
+ /** Content identity for sharing, including portable hidden render inputs. The Desktop watch inventory stays separate.
18
+ * @param {string} projectPath @returns {Promise<string | null>} */
19
+ export async function shareSourceFingerprint(projectPath) {
20
+ const capture = await captureProject(await realpath(projectPath));
21
+ return capture.at === "captured" && !captureRefusal(capture) ? capture.manifest.sha256 : null;
22
+ }
23
+ /** @param {Binding} binding @param {string} space */
24
+ function bindingRefusal(binding, space) {
25
+ if (binding.ownerSpaceId !== space) return "source_other_workspace";
26
+ if (binding.sharedFrom) return "source_owned_by_someone_else";
27
+ if (binding.gettingFromCloud || binding.duplicate) return "source_operation_in_progress";
28
+ return null;
29
+ }
30
+ /** @param {import("./sources/types.js").Capture} capture */
31
+ function captureRefusal(capture) {
32
+ if (capture.at === "too-big") return `source_${capture.over.limit}_exceeded`;
33
+ if (capture.at === "problems") return `source_${capture.problems[0]?.problem ?? "invalid"}`;
34
+ if (capture.settled.agentTurnOpen || capture.settled.projectLocked || capture.waiting.length)
35
+ return "source_edit_in_progress";
36
+ return null;
37
+ }
38
+
39
+ /** Prepare source explicitly before adding the video version. No unexpected network or filesystem failure is
40
+ * converted to video-only. `filesForExport` revalidates the prepared bytes and never starts another upload.
41
+ * @param {import("./sources/adapterTypes.js").SourceOptions} options */
42
+ export function shareSources(options) {
43
+ const store = options.store ?? createProjectShareSourceStore();
44
+ const fingerprint = options.fingerprint ?? projectFingerprint;
45
+ const signal = options.signal ?? new AbortController().signal;
46
+ /** @type {Map<string, { rendered: string, digest: string, snapshot: import("./types/reviewWire.js").SnapshotRef, account: string, backend: string }>} */
47
+ const prepared = new Map();
48
+ /** @param {string} projectPath @param {string | null} rendered @returns {Promise<SourceResult>} */
49
+ const prepare = async (projectPath, rendered) => {
50
+ const root = await realpath(projectPath);
51
+ prepared.delete(root);
52
+ if (!enabled(options)) return unavailable("source_disabled");
53
+ if (!rendered || (await fingerprint(root)) !== rendered)
54
+ return unavailable("source_render_changed");
55
+ return withProjectShareLock(
56
+ root,
57
+ "source",
58
+ async () => {
59
+ const signed = await options.access();
60
+ if (!signed?.owner) return unavailable("source_signed_out");
61
+ const key = { backend: signed.heygenApi, account: signed.owner, projectPath: root };
62
+ const guardedAccess = async (/** @type {string | undefined} */ refused) => {
63
+ const next = await options.access(refused);
64
+ if (!next || next.owner !== key.account || next.heygenApi !== key.backend)
65
+ throw new Error("Source account or backend changed while sharing.");
66
+ return next;
67
+ };
68
+ const api =
69
+ options.api ?? sourceApi({ access: guardedAccess, fetchImpl: options.fetchImpl });
70
+ const { own, existing, binding } = await selectBinding(key, options, store);
71
+ const refused = bindingRefusal(binding, options.spaceId);
72
+ if (refused) return unavailable(refused);
73
+ const result = await prepareBinding({
74
+ binding,
75
+ own: Boolean(own),
76
+ external: Boolean(existing),
77
+ api,
78
+ store,
79
+ options,
80
+ signal,
81
+ fingerprint,
82
+ rendered,
83
+ });
84
+ const manifest = result.manifest;
85
+ await guardedAccess(undefined);
86
+ if (result.at !== "ready" || !manifest) return result;
87
+ if (!(await stillMatches(root, rendered, manifest.sha256, fingerprint)))
88
+ return unavailable("source_render_changed");
89
+ prepared.set(root, {
90
+ rendered,
91
+ digest: manifest.sha256,
92
+ snapshot: result.snapshot,
93
+ account: key.account,
94
+ backend: key.backend,
95
+ });
96
+ return { at: "ready", snapshot: result.snapshot };
97
+ },
98
+ { signal },
99
+ );
100
+ };
101
+ /** @param {string} projectPath @param {string | null} rendered */
102
+ const filesForExport = async (projectPath, rendered) => {
103
+ const root = await realpath(projectPath);
104
+ const held = prepared.get(root);
105
+ const signed = await options.access();
106
+ if (
107
+ !held ||
108
+ !rendered ||
109
+ held.rendered !== rendered ||
110
+ !sameOwner(signed, held) ||
111
+ !enabled(options)
112
+ )
113
+ return null;
114
+ return (await stillMatches(root, rendered, held.digest, fingerprint)) ? held.snapshot : null;
115
+ };
116
+ return { prepare, filesForExport };
117
+ }
118
+
119
+ /** @param {import("./sources/adapterTypes.js").SourceOptions} options */
120
+ const enabled = (options) => options.enabled?.() ?? true;
121
+ /** @param {Awaited<ReturnType<import("./signedInRequest.mjs").SignedInAccess>>} signed
122
+ * @param {{account: string, backend: string}} held */
123
+ const sameOwner = (signed, held) =>
124
+ signed?.owner === held.account && signed?.heygenApi === held.backend;
125
+ /** @typedef {{ binding: Binding, own: boolean, external: boolean, api: import("./sources/api.mjs").SourceApi,
126
+ * store: import("./sources/adapterTypes.js").SourceStore, options: import("./sources/adapterTypes.js").SourceOptions,
127
+ * signal: AbortSignal, fingerprint: (path: string) => Promise<string | null>, rendered: string }} Preparation */
128
+ /** @param {Preparation} job @returns {Promise<SourceResult & {manifest?: import("./sources/types.js").Manifest}>} */
129
+ async function prepareBinding(job) {
130
+ const { binding, api, fingerprint, rendered } = job;
131
+ const limits = await api.limits(binding.ownerSpaceId);
132
+ if (!limits.syncAllowed) return unavailable("source_plan_required");
133
+ const capture = await captureProject(binding.projectPath, { limits: limits.push });
134
+ const bad = captureRefusal(capture);
135
+ if (bad) return unavailable(bad);
136
+ if (capture.at !== "captured") return unavailable("source_invalid");
137
+ if ((await fingerprint(binding.projectPath)) !== rendered)
138
+ return unavailable("source_render_changed");
139
+ const result = await snapshotFor(job, capture.manifest, limits);
140
+ return { ...result, manifest: capture.manifest };
141
+ }
142
+ /** @param {Preparation} job @param {import("./sources/types.js").Manifest} manifest
143
+ * @param {Extract<import("./sources/types.js").SyncLimitsRead, {at: "read"}>} limits @returns {Promise<SourceResult>} */
144
+ async function snapshotFor(job, manifest, limits) {
145
+ const { binding, api, store, options, signal } = job;
146
+ const blocked = await recoverEarlierCapture(job, manifest.sha256);
147
+ if (blocked) return blocked;
148
+ if (binding.base?.manifestSha256 === manifest.sha256 && !binding.inflight)
149
+ return reuseSnapshot(api, binding, manifest.sha256);
150
+ if (job.external) return unavailable("source_sync_required");
151
+ if (!job.own && limits.compositions >= limits.maxCompositions)
152
+ return unavailable("source_composition_limit");
153
+ await store.write(binding);
154
+ binding.composition = await api.create(
155
+ binding.ownerSpaceId,
156
+ binding.compositionId,
157
+ basename(binding.projectPath),
158
+ );
159
+ await store.write(binding);
160
+ return uploadSource({ binding, manifest, api, store, signal, ...uploadOptions(options) });
161
+ }
162
+ /** @param {Preparation} job @param {string} digest */
163
+ function recoverEarlierCapture({ external, binding, api, store }, digest) {
164
+ const inflight = binding.inflight;
165
+ if (external || !inflight || inflight.manifestSha256 === digest) return null;
166
+ return recoverSourceUpload({ binding, api, store });
167
+ }
168
+
169
+ /** @param {import("./sources/adapterTypes.js").SourceOptions} options */
170
+ function uploadOptions(options) {
171
+ return {
172
+ fetchImpl: options.fetchImpl ?? fetch,
173
+ wait: options.wait ?? realWait,
174
+ timeoutMs: options.verificationTimeoutMs ?? 120_000,
175
+ onProgress: options.onProgress ?? (() => {}),
176
+ };
177
+ }
178
+
179
+ /** @param {import("./sources/types.js").BindingKey} key @param {import("./sources/adapterTypes.js").SourceOptions} options
180
+ * @param {import("./sources/adapterTypes.js").SourceStore} store */
181
+ async function selectBinding(key, options, store) {
182
+ const own = await store.read(key);
183
+ const existing = own ? null : await options.existingBinding?.(key);
184
+ return { own, existing, binding: own ?? existing ?? newBinding(key, options.spaceId) };
185
+ }
186
+ /** @param {import("./sources/types.js").BindingKey} key @param {string} space @returns {Binding} */
187
+ function newBinding(key, space) {
188
+ return {
189
+ ...key,
190
+ version: 1,
191
+ compositionId: randomUUID(),
192
+ ownerSpaceId: space,
193
+ boundAt: Date.now(),
194
+ composition: null,
195
+ etag: null,
196
+ base: null,
197
+ inflight: null,
198
+ };
199
+ }
200
+ /** @param {import("./sources/api.mjs").SourceApi} api @param {Binding} binding @param {string} digest @returns {Promise<SourceResult>} */
201
+ async function reuseSnapshot(api, binding, digest) {
202
+ if (!binding.base) return unavailable("source_sync_required");
203
+ const remote = await api.snapshot(
204
+ binding.ownerSpaceId,
205
+ binding.compositionId,
206
+ binding.base.snapshotId,
207
+ );
208
+ if (
209
+ remote.id !== binding.base.snapshotId ||
210
+ remote.compositionId !== binding.compositionId ||
211
+ remote.manifestSha256 !== digest ||
212
+ remote.manifest?.sha256 !== digest ||
213
+ remote.publicationOutcome !== "published"
214
+ )
215
+ return unavailable("source_snapshot_mismatch");
216
+ return {
217
+ at: "ready",
218
+ snapshot: {
219
+ space_id: binding.ownerSpaceId,
220
+ composition_id: binding.compositionId,
221
+ snapshot_id: remote.id,
222
+ },
223
+ };
224
+ }
225
+ /** @param {string} root @param {string} rendered @param {string} digest @param {(path: string) => Promise<string | null>} fingerprint */
226
+ async function stillMatches(root, rendered, digest, fingerprint) {
227
+ if ((await fingerprint(root)) !== rendered) return false;
228
+ const capture = await captureProject(root);
229
+ return (
230
+ !captureRefusal(capture) &&
231
+ capture.at === "captured" &&
232
+ capture.manifest.sha256 === digest &&
233
+ (await fingerprint(root)) === rendered
234
+ );
235
+ }
236
+ export { createProjectShareSourceStore } from "./sources/store.mjs";