@hyperframes/share 0.9.28-alpha.1 → 0.9.28

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 (56) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +1 -58
  3. package/dist/index.d.mts +0 -1
  4. package/dist/projectLock.d.mts +2 -2
  5. package/dist/projectSnapshot.d.mts +1 -3
  6. package/dist/reviewClient.d.mts +16 -13
  7. package/dist/reviewExport.d.mts +1 -11
  8. package/dist/reviewUpload.d.mts +0 -2
  9. package/package.json +1 -1
  10. package/scripts/copy-types.mjs +0 -3
  11. package/src/index.mjs +0 -6
  12. package/src/projectLock.mjs +2 -9
  13. package/src/projectSnapshot.mjs +6 -22
  14. package/src/reviewClient.mjs +83 -48
  15. package/src/reviewExport.mjs +41 -156
  16. package/src/reviewUpload.mjs +16 -18
  17. package/src/reviewVersionSentences.mjs +1 -2
  18. package/dist/shareSources.d.mts +0 -24
  19. package/dist/sourceIgnore.d.mts +0 -3
  20. package/dist/sources/adapterTypes.d.ts +0 -39
  21. package/dist/sources/api.d.mts +0 -29
  22. package/dist/sources/capture.d.mts +0 -24
  23. package/dist/sources/contract.d.mts +0 -2
  24. package/dist/sources/errors.d.mts +0 -24
  25. package/dist/sources/limits.d.mts +0 -12
  26. package/dist/sources/manifest.d.mts +0 -23
  27. package/dist/sources/pathPolicy.d.mts +0 -25
  28. package/dist/sources/projectState.d.mts +0 -9
  29. package/dist/sources/store.d.mts +0 -3
  30. package/dist/sources/transfer.d.mts +0 -45
  31. package/dist/sources/types.d.ts +0 -353
  32. package/dist/sources/unicode.d.mts +0 -15
  33. package/dist/sources/upload.d.mts +0 -9
  34. package/dist/sources/uploadFiles.d.mts +0 -31
  35. package/dist/sources/wire.d.mts +0 -21
  36. package/src/shareSources.mjs +0 -236
  37. package/src/sourceIgnore.mjs +0 -69
  38. package/src/sources/adapterTypes.d.ts +0 -39
  39. package/src/sources/api.mjs +0 -114
  40. package/src/sources/capture.mjs +0 -264
  41. package/src/sources/contract/LICENSE-Unicode.txt +0 -39
  42. package/src/sources/contract/path-policy.json +0 -311
  43. package/src/sources/contract/unicode-15.1.0.json +0 -6179
  44. package/src/sources/contract.mjs +0 -13
  45. package/src/sources/errors.mjs +0 -77
  46. package/src/sources/limits.mjs +0 -48
  47. package/src/sources/manifest.mjs +0 -154
  48. package/src/sources/pathPolicy.mjs +0 -197
  49. package/src/sources/projectState.mjs +0 -59
  50. package/src/sources/store.mjs +0 -82
  51. package/src/sources/transfer.mjs +0 -196
  52. package/src/sources/types.d.ts +0 -353
  53. package/src/sources/unicode.mjs +0 -163
  54. package/src/sources/upload.mjs +0 -201
  55. package/src/sources/uploadFiles.mjs +0 -263
  56. package/src/sources/wire.mjs +0 -205
@@ -92,6 +92,7 @@ export function reviewClient({ access, fetchImpl = fetch, signedInEmail = async
92
92
  * @returns {Promise<{ versionSharing: boolean, shareFiles: boolean }>} */
93
93
  reviewFlags: async () => {
94
94
  const data = await call("GET", SPACES, undefined, {}, SHARING_REFUSALS);
95
+ if (!Array.isArray(data?.items)) throw new ReviewFailed(502, "malformed_spaces");
95
96
  return {
96
97
  versionSharing: data?.version_sharing === true,
97
98
  shareFiles: data?.share_files === true,
@@ -152,9 +153,8 @@ function threadWrites(call, project) {
152
153
  };
153
154
  }
154
155
 
155
- /** An invite to an email whose grant was revoked: the server keeps the revoked row, leaves it out of the grant list,
156
- * and takes it back only with that row's revision and a fresh operation ID, so the one revision the client can learn is
157
- * the conflict's. Null for any other refusal, or one the same revision already got.
156
+ /** An invite to an email whose grant was revoked: the server takes the row back only at its revision with a fresh
157
+ * operation ID, here the conflict's revision. Null for any other refusal, or one the same revision already got.
158
158
  * @param {unknown} error @param {import("./types/reviewWire.js").CreateGrantRequest} body
159
159
  * @returns {import("./types/reviewWire.js").CreateGrantRequest | null} */
160
160
  function regrantOf(error, body) {
@@ -166,17 +166,24 @@ function regrantOf(error, body) {
166
166
  return { ...body, client_operation_id: randomUUID(), expected_revision: revision };
167
167
  }
168
168
 
169
- /** Creating a grant, keeping the re-grant `regrantOf` makes as that invitation's retry. The window retries with the
170
- * operation it first sent; once a re-grant went out under a new operation, that exact re-grant is sent again, so the
171
- * server's replay recovers a lost answer (or refuses it as revoked since) and no further re-grant is made. It goes
172
- * exactly when the window drops its own operation: on an answer, on a refusal `rejectedGrantRequest` (the window's own
173
- * test) calls final, or when the email is invited afresh. Any other refusal, a moderation hold among them, keeps it.
169
+ /** Grants listed with their revoked rows, whose revisions let a re-invite go straight out as the re-grant; a server
170
+ * that ignores `include_revoked` lists none, and the re-invite meets `revision_conflict` first. A re-grant is kept as
171
+ * its invitation's retry, sent again as itself until an answer or a refusal `rejectedGrantRequest` calls final.
174
172
  * @param {(method: string, route: string, body?: unknown) => Promise<any>} call @param {(id: string) => string} project
175
173
  * @param {import("./types/access.js").ApiAccess} access */
176
- function grantCreator(call, project, access) {
174
+ function grantCalls(call, project, access) {
177
175
  /** Re-grants awaiting a final answer, by site, account, project and the operation the window sent.
178
176
  * @type {Map<string, { email: string, regrant: import("./types/reviewWire.js").CreateGrantRequest }>} */
179
177
  const sent = new Map();
178
+ /** The revision of each revoked grant the last list read, by site, account and project, then email.
179
+ * @type {Map<string, Map<string, number>>} */
180
+ const revoked = new Map();
181
+ /** @param {string} id */
182
+ const scopeOf = async (id) => {
183
+ // expected: signed out, the call itself refuses as login_required; the scope is only a key.
184
+ const who = await access().catch(() => null);
185
+ return [who?.heygen ?? "", who?.owner ?? "", id].join("\n");
186
+ };
180
187
  /** @param {string} route @param {string} key @param {import("./types/reviewWire.js").CreateGrantRequest} body */
181
188
  const settle = async (route, key, body) => {
182
189
  try {
@@ -188,29 +195,76 @@ function grantCreator(call, project, access) {
188
195
  throw error;
189
196
  }
190
197
  };
191
- /** @param {string} id @param {import("./types/reviewWire.js").CreateGrantRequest} body
192
- * @returns {Promise<import("./types/reviewWire.js").AccessGrantDTO>} */
193
- return async (id, body) => {
194
- const route = `${project(id)}/access-grants`;
195
- // expected: signed out, the POST below refuses as login_required; the scope is only a key.
196
- const who = await access().catch(() => null);
197
- const scope = grantScope(who, id);
198
- const key = `${scope}\n${body.client_operation_id}`;
199
- const retained = sent.get(key);
200
- if (retained) return settle(route, key, retained.regrant);
198
+ /** @param {string} route @param {string} key @param {import("./types/reviewWire.js").CreateGrantRequest} body */
199
+ const regranting = (route, key, body) => {
200
+ sent.set(key, { email: body.email, regrant: body });
201
+ return settle(route, key, body);
202
+ };
203
+ /** @param {string} scope @param {string} email */
204
+ const forgetEarlierInvites = (scope, email) => {
201
205
  for (const [other, kept] of sent)
202
- if (other.startsWith(`${scope}\n`) && kept.email === body.email) sent.delete(other);
203
- try {
204
- return await call("POST", route, body);
205
- } catch (error) {
206
- const regrant = regrantOf(error, body);
207
- if (!regrant) throw error;
208
- sent.set(key, { email: body.email, regrant });
209
- return settle(route, key, regrant);
210
- }
206
+ if (other.startsWith(`${scope}\n`) && kept.email === email) sent.delete(other);
207
+ };
208
+ return {
209
+ /** The project's live grants. @param {string} id
210
+ * @returns {Promise<{items: import("./types/reviewWire.js").AccessGrantDTO[]}>} */
211
+ grants: async (id) => {
212
+ const scope = await scopeOf(id);
213
+ const items = await everyGrant(call, `${project(id)}/access-grants`);
214
+ revoked.set(
215
+ scope,
216
+ new Map(
217
+ items.filter((grant) => grant.revoked_at).map((grant) => [grant.email, grant.revision]),
218
+ ),
219
+ );
220
+ return { items: items.filter((grant) => !grant.revoked_at) };
221
+ },
222
+ /** @param {string} id @param {import("./types/reviewWire.js").CreateGrantRequest} body
223
+ * @returns {Promise<import("./types/reviewWire.js").AccessGrantDTO>} */
224
+ createGrant: async (id, body) => {
225
+ const route = `${project(id)}/access-grants`;
226
+ const scope = await scopeOf(id);
227
+ const key = `${scope}\n${body.client_operation_id}`;
228
+ const retained = sent.get(key);
229
+ if (retained) return settle(route, key, retained.regrant);
230
+ forgetEarlierInvites(scope, body.email);
231
+ const known =
232
+ body.expected_revision === null ? revoked.get(scope)?.get(body.email) : undefined;
233
+ revoked.get(scope)?.delete(body.email);
234
+ const first =
235
+ known === undefined
236
+ ? body
237
+ : { ...body, client_operation_id: randomUUID(), expected_revision: known };
238
+ try {
239
+ return await (first === body ? call("POST", route, body) : regranting(route, key, first));
240
+ } catch (error) {
241
+ const regrant = regrantOf(error, first);
242
+ if (!regrant) throw error;
243
+ return regranting(route, key, regrant);
244
+ }
245
+ },
211
246
  };
212
247
  }
213
248
 
249
+ /** Every page of a grant list, revoked rows included where the server lists them.
250
+ * @param {(method: string, route: string) => Promise<any>} call @param {string} route
251
+ * @returns {Promise<import("./types/reviewWire.js").AccessGrantDTO[]>} */
252
+ async function everyGrant(call, route) {
253
+ const items = [];
254
+ const cursors = new Set();
255
+ let cursor = null;
256
+ do {
257
+ const query = new URLSearchParams({ include_revoked: "true", ...(cursor ? { cursor } : {}) });
258
+ const page = await call("GET", `${route}?${query}`);
259
+ items.push(...page.items);
260
+ cursor = page.next_cursor;
261
+ if (cursor && (cursors.has(cursor) || cursors.size >= 1000))
262
+ throw new ReviewFailed(502, "malformed_grant_pagination");
263
+ cursors.add(cursor);
264
+ } while (cursor);
265
+ return items;
266
+ }
267
+
214
268
  /** @typedef {ReturnType<typeof reviewClient>} ReviewClient
215
269
  * @typedef {import("./types/reviewWire.js").ExportRequest} ExportRequest */
216
270
 
@@ -229,23 +283,7 @@ function managementClient(call, project, access) {
229
283
  }),
230
284
  /** @param {string} id @returns {Promise<null>} */
231
285
  removeProject: (id) => call("DELETE", project(id)),
232
- /** @param {string} id @returns {Promise<{items: import("./types/reviewWire.js").AccessGrantDTO[]}>} */
233
- grants: async (id) => {
234
- const items = [];
235
- const cursors = new Set();
236
- let cursor = null;
237
- do {
238
- const query = new URLSearchParams(cursor ? { cursor } : {});
239
- const page = await call("GET", `${project(id)}/access-grants?${query}`);
240
- items.push(...page.items);
241
- cursor = page.next_cursor;
242
- if (cursor && (cursors.has(cursor) || cursors.size >= 1000))
243
- throw new ReviewFailed(502, "malformed_grant_pagination");
244
- cursors.add(cursor);
245
- } while (cursor);
246
- return { items };
247
- },
248
- createGrant: grantCreator(call, project, access),
286
+ ...grantCalls(call, project, access),
249
287
  /** @param {string} id @param {string} grantId @param {{role: import("./types/reviewWire.js").AccessGrantDTO["role"],
250
288
  * expected_revision: number}} body @returns {Promise<import("./types/reviewWire.js").AccessGrantDTO>} */
251
289
  updateGrant: (id, grantId, body) =>
@@ -255,6 +293,3 @@ function managementClient(call, project, access) {
255
293
  call("DELETE", `${project(id)}/access-grants/${encodeURIComponent(grantId)}`),
256
294
  };
257
295
  }
258
-
259
- /** @param {Awaited<ReturnType<import("./types/access.js").ApiAccess>>} who @param {string} id */
260
- const grantScope = (who, id) => [who?.heygen ?? "", who?.owner ?? "", id].join("\n");
@@ -39,10 +39,9 @@ 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
- * 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 */
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 */
46
45
 
47
46
  /** `onAdded`: a version went ready, with whether it carries files and whether it is the page's first from here.
48
47
  * @typedef {{ onProgress?: (progress: ReviewExportProgress | null) => void, onUploaded?: () => Promise<void>,
@@ -50,11 +49,10 @@ import { versionSentence } from "./reviewVersionSentences.mjs";
50
49
 
51
50
  /** `projectId` overrides the recorded review project; `open`: a held video is watched only while the project is open.
52
51
  * `filesForExport`: the synced snapshot a new version carries, null for
53
- * none. `allowPending` lets a host treat accepted processing or screening as a successful return.
52
+ * none.
54
53
  * @typedef {VersionHooks & { projectPath: string, title: string, client: ReviewClient, put?: typeof putFile,
55
54
  * sleep?: (ms: number) => Promise<void>, probe?: (file: string) => Promise<VideoFacts | null>, now?: () => number,
56
- * projectId?: string | null, filesForExport?: FilesForRender, fromReview?: FromReview | null,
57
- * allowPending?: boolean, requireSource?: boolean, omitSource?: boolean }} VersionRequest */
55
+ * projectId?: string | null, filesForExport?: FilesForRender, fromReview?: FromReview | null }} VersionRequest */
58
56
 
59
57
  /** The snapshot of `rendered`, the project as the export was rendered; null when the folder is not that render.
60
58
  * @typedef {(projectPath: string, rendered: string | null) => Promise<import("./types/reviewWire.js").SnapshotRef | null>}
@@ -179,36 +177,18 @@ function isLast(value) {
179
177
  isSavedId(value.versionId) &&
180
178
  isSavedId(value.exportedAt) &&
181
179
  isSavedId(value.projectId) &&
182
- isLastMetadata(value)
183
- );
184
- }
185
-
186
- /** @param {Record<string, unknown>} value */
187
- function isLastMetadata(value) {
188
- return (
189
180
  isOptionalBoolean(value.files) &&
190
- isOptionalSnapshot(value.snapshot) &&
191
181
  (value.checksum === undefined || isSavedId(value.checksum))
192
182
  );
193
183
  }
194
184
 
195
185
  /** @param {Record<string, unknown>} saved
196
- * @returns {saved is {pending?: VersionRecord["pending"], last?: SavedLast | null, failure?: unknown}} */
186
+ * @returns {saved is {pending?: VersionRecord["pending"], last?: SavedLast | null}} */
197
187
  const isExportState = (saved) =>
198
188
  ("pending" in saved || "last" in saved) &&
199
189
  (saved.pending == null || isPending(saved.pending)) &&
200
190
  (saved.last == null || isLast(saved.last));
201
191
 
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
-
212
192
  /** @param {string} dir @returns {Promise<VersionRecord>} */
213
193
  async function readState(dir) {
214
194
  const file = statePath(dir);
@@ -218,7 +198,6 @@ async function readState(dir) {
218
198
  const { last, pending } = saved;
219
199
  return {
220
200
  pending: pending ?? null,
221
- failure: isSavedFailure(saved.failure) ? saved.failure : null,
222
201
  last: typeof last?.fingerprint === "string" ? { ...last, fingerprint: last.fingerprint } : null,
223
202
  };
224
203
  }
@@ -255,7 +234,7 @@ const failures = new Map();
255
234
  /** Which export a failure was about: the newest export file and the project as it was rendered. @param {string} dir */
256
235
  const exportKeyOf = (dir) => {
257
236
  const latest = latestExportFile(dir);
258
- return latest ? `${latest.path}\n${latest.fingerprint ?? ""}\n${latest.id}` : "";
237
+ return latest ? `${latest.path}\n${latest.fingerprint ?? ""}` : "";
259
238
  };
260
239
 
261
240
  /** Whether the newest export can go up now: there is one, it is MP4, and the project has not moved past it.
@@ -280,8 +259,7 @@ export async function reviewVersion(projectPath, server = {}) {
280
259
  const last = state.last?.projectId === projectId ? state.last : null;
281
260
  const onServer =
282
261
  server.latestReadyVersionId === undefined || server.latestReadyVersionId === last?.versionId;
283
- const savedFailure = state.failure?.projectId === projectId ? state.failure : null;
284
- const failed = savedFailure ?? failures.get(key);
262
+ const failed = failures.get(key);
285
263
  return {
286
264
  last: last
287
265
  ? { exportedAt: last.exportedAt, current: last.fingerprint === now && onServer }
@@ -301,8 +279,8 @@ export async function reviewVersion(projectPath, server = {}) {
301
279
 
302
280
  /** The newest MP4 and the body that adds it; a stale, non-MP4, too big or too long export never sends anything.
303
281
  * @param {string} dir @param {string} title @param {(file: string) => Promise<VideoFacts | null>} probe
304
- * @param {FilesForRender} filesForExport @param {boolean} requireSource */
305
- async function exportSource(dir, title, probe, filesForExport, requireSource) {
282
+ * @param {FilesForRender} filesForExport */
283
+ async function exportSource(dir, title, probe, filesForExport) {
306
284
  const latest = latestExportFile(dir);
307
285
  if (!latest || sourceOf(dir, await projectFingerprint(dir)) !== "ready")
308
286
  return { failure: local("Export an MP4 of the project as it is now first.") };
@@ -329,32 +307,16 @@ async function exportSource(dir, title, probe, filesForExport, requireSource) {
329
307
  content_type: "video/mp4",
330
308
  },
331
309
  };
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
- };
344
- if (snapshot) body.snapshot = snapshot;
345
- return { file: latest.path, body };
346
- }
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);
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
+ );
356
316
  return null;
357
317
  });
318
+ if (snapshot) body.snapshot = snapshot;
319
+ return { file: latest.path, body };
358
320
  }
359
321
 
360
322
  /** @param {ExportRequest["snapshot"]} a @param {ExportRequest["snapshot"]} b */
@@ -406,7 +368,7 @@ async function createdExport(r, id, pending, kept) {
406
368
  return await sendCreate(r, id, pending, kept);
407
369
  } catch (error) {
408
370
  const refused = error instanceof ReviewFailed && error.reason === "snapshot_mismatch";
409
- if (!refused || !pending.body.snapshot || pending.unanswered || r.requireSource) throw error;
371
+ if (!refused || !pending.body.snapshot || pending.unanswered) throw error;
410
372
  console.error("[review] snapshot refused; the version goes up without files:", error);
411
373
  const body = { ...pending.body, client_operation_id: `export-${randomUUID()}` };
412
374
  delete body.snapshot;
@@ -602,31 +564,17 @@ const spent = (error, pending) =>
602
564
  * @param {string} id @param {ExportDTO} done @param {FromReview | null} fromReview @returns {Promise<Said | null>} */
603
565
  async function finish(dir, state, pending, id, done, fromReview) {
604
566
  const ready = done.status === "ready";
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
- });
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 });
614
576
  if (ready) await sharedFromReview(fromReview, dir, pending.reviewSent?.since);
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}`;
577
+ return ready ? null : said(new ReviewFailed(200, done.error?.reason ?? ""));
630
578
  }
631
579
 
632
580
  /** @param {Required<VersionRequest>} r @param {(progress: ReviewExportProgress) => void} report
@@ -634,37 +582,17 @@ async function failedExportKey(dir, pending) {
634
582
  async function run(r, report) {
635
583
  report({ step: "preparing", sent: 0, total: 0 });
636
584
  const state = await readState(r.projectPath);
637
- const source = await exportSource(
638
- r.projectPath,
639
- r.title,
640
- r.probe,
641
- r.omitSource ? noSource : r.filesForExport,
642
- r.requireSource,
643
- );
585
+ const source = await exportSource(r.projectPath, r.title, r.probe, r.filesForExport);
644
586
  if ("failure" in source) return source.failure ?? null;
645
587
  const id = r.projectId ?? (await reviewProjectId(r.projectPath));
646
588
  if (!id) return local("Share the project first, then add a version.");
647
- if (
648
- sameSourceMode(r, state.last, source.body.snapshot) &&
649
- (await alreadyUploaded(r.client, id, state, source.body))
650
- )
651
- return null;
589
+ if (await alreadyUploaded(r.client, id, state, source.body)) return null;
652
590
  return sendVersion(r, report, source, id, state);
653
591
  }
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
-
663
592
  /** @param {ReviewClient} client @param {string} id @param {VersionRecord} state @param {ExportRequest} body */
664
593
  async function alreadyUploaded(client, id, state, body) {
665
594
  const last = state.last;
666
- if (state.pending || state.failure?.projectId === id || !last || last.projectId !== id)
667
- return false;
595
+ if (state.pending || !last || last.projectId !== id) return false;
668
596
  if (
669
597
  last.fingerprint !== body.source_fingerprint ||
670
598
  last.checksum !== body.local_upload.checksum_sha256
@@ -676,10 +604,8 @@ async function alreadyUploaded(client, id, state, body) {
676
604
  * @param {{file: string, body: ExportRequest}} source @param {string} id @param {VersionRecord} state */
677
605
  async function sendVersion(r, report, source, id, state) {
678
606
  const first = state.last?.projectId !== id;
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 }));
607
+ let pending = bodyToSend(state, source.body, id);
608
+ await writeState(r.projectPath, (state = { ...state, pending }));
683
609
  try {
684
610
  const answer = await createdExport(r, id, pending, (next) =>
685
611
  writeState(r.projectPath, (state = { ...state, pending: next })),
@@ -705,35 +631,16 @@ async function sendVersion(r, report, source, id, state) {
705
631
  const done = await watch(r, id, uploaded, size, report, clock);
706
632
  return finishRun(r, state, pending, id, done, first);
707
633
  } catch (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;
634
+ if (spent(error, state.pending)) await writeState(r.projectPath, { ...state, pending: null });
635
+ console.error("[review] version failed:", error);
636
+ return said(error);
719
637
  }
720
638
  }
721
639
 
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
-
733
640
  /** @param {Required<VersionRequest>} r @param {VersionRecord} state
734
641
  * @param {NonNullable<VersionRecord["pending"]>} pending @param {string} id @param {ExportDTO} done @param {boolean} first */
735
642
  async function finishRun(r, state, pending, id, done, first) {
736
- if (going(done)) return r.allowPending ? null : stillGoing(done);
643
+ if (going(done)) return stillGoing(done);
737
644
  const failed = await finish(r.projectPath, state, pending, id, done, r.fromReview);
738
645
  if (!failed) {
739
646
  const fromReview = pending.reviewSent?.value;
@@ -756,11 +663,8 @@ export async function addVersion(request) {
756
663
  now: () => Date.now(),
757
664
  onProgress: () => {},
758
665
  open: () => true,
759
- allowPending: false,
760
- filesForExport: noSource,
666
+ filesForExport: async () => null,
761
667
  fromReview: null,
762
- requireSource: false,
763
- omitSource: false,
764
668
  onAdded: () => {},
765
669
  ...request,
766
670
  projectId: request.projectId ?? null,
@@ -781,6 +685,7 @@ export async function addVersion(request) {
781
685
  if (kept) failures.set(key, { ...failure, exportKey: exportKeyOf(r.projectPath) });
782
686
  return failure?.sentence ?? null;
783
687
  } catch (error) {
688
+ console.error("[review] version failed:", error);
784
689
  const sentence =
785
690
  error instanceof SharingStateError ? error.message : "Couldn't add this version. Try again.";
786
691
  failures.set(key, {
@@ -850,7 +755,7 @@ async function settleSavedExport(dir, client, fromReview) {
850
755
  const id = await reviewProjectId(dir);
851
756
  if (!pending?.videoId || !id || pending.projectId !== id) return;
852
757
  const done = await client.readExport(id, pending.videoId);
853
- if (done.status === "upload_pending" || going(done)) return;
758
+ if (going(done)) return;
854
759
  const failure = await finish(dir, state, pending, id, done, fromReview);
855
760
  const key = normalize(dir);
856
761
  if (failure) failures.set(key, { ...failure, exportKey: exportKeyOf(dir) });
@@ -920,23 +825,3 @@ export async function pendingReviewExport(projectPath) {
920
825
  checksum: pending.body.local_upload.checksum_sha256,
921
826
  };
922
827
  }
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,22 +18,6 @@ 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
-
37
21
  /** Streams the file to the signed URL through main's fetch (the app's proxy), the server's headers as given plus
38
22
  * the length, and no bearer. Nothing sent or answered for `stallMs` ends it as offline, never "Uploading" forever.
39
23
  * @param {{ put_url: string, headers: Record<string, string> }} upload @param {string} file @param {number} size
@@ -45,8 +29,22 @@ export async function putFile(
45
29
  onSent,
46
30
  { stallMs = 60_000, fetchImpl = fetch } = {},
47
31
  ) {
48
- const target = trustedUploadUrl(upload.put_url);
49
- if (!target) return "refused";
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
+ }
50
48
  const stall = new AbortController();
51
49
  /** @type {ReturnType<typeof setTimeout> | undefined} */
52
50
  let timer;
@@ -14,8 +14,7 @@ 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:
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",
17
+ export_blocked: "Adding videos to review pages isn't available for this account.",
19
18
  feature_unavailable: "Review pages aren't available for your account yet.",
20
19
  permission_denied: "This account can't add videos to this review page yet.",
21
20
  upload_expired: "The upload took too long. Try again.",
@@ -1,24 +0,0 @@
1
- /** Content identity for sharing, including portable hidden render inputs. The Desktop watch inventory stays separate.
2
- * @param {string} projectPath @returns {Promise<string | null>} */
3
- export function shareSourceFingerprint(projectPath: string): Promise<string | null>;
4
- /** Prepare source explicitly before adding the video version. No unexpected network or filesystem failure is
5
- * converted to video-only. `filesForExport` revalidates the prepared bytes and never starts another upload.
6
- * @param {import("./sources/adapterTypes.js").SourceOptions} options */
7
- export function shareSources(options: import("./sources/adapterTypes.js").SourceOptions): {
8
- prepare: (projectPath: string, rendered: string | null) => Promise<SourceResult>;
9
- filesForExport: (projectPath: string, rendered: string | null) => Promise<import("./types/reviewWire.js").SnapshotRef | null>;
10
- };
11
- export { createProjectShareSourceStore } from "./sources/store.mjs";
12
- export type Preparation = {
13
- binding: Binding;
14
- own: boolean;
15
- external: boolean;
16
- api: import("./sources/api.mjs").SourceApi;
17
- store: import("./sources/adapterTypes.js").SourceStore;
18
- options: import("./sources/adapterTypes.js").SourceOptions;
19
- signal: AbortSignal;
20
- fingerprint: (path: string) => Promise<string | null>;
21
- rendered: string;
22
- };
23
- export type SourceResult = import("./sources/adapterTypes.js").SourceResult;
24
- export type Binding = import("./sources/types.js").Binding;
@@ -1,3 +0,0 @@
1
- /** Explicit, project-relative exclusions. This only removes files; it cannot override safety exclusions.
2
- * @param {string} root @returns {Promise<(path: string) => boolean>} */
3
- export function readSourceIgnore(root: string): Promise<(path: string) => boolean>;
@@ -1,39 +0,0 @@
1
- import type { Binding, BindingKey, Manifest } from "./types.js";
2
- import type { SnapshotRef } from "../types/reviewWire.js";
3
- import type { SignedInAccess } from "../signedInRequest.mjs";
4
- import type { SourceApi } from "./api.mjs";
5
-
6
- export interface SourceStore {
7
- read(key: BindingKey): Promise<Binding | null>;
8
- write(binding: Binding): Promise<void>;
9
- }
10
- export type SourceResult =
11
- | { at: "ready"; snapshot: SnapshotRef }
12
- | { at: "unavailable"; reason: string };
13
- export interface SourceOptions {
14
- access: SignedInAccess;
15
- spaceId: string;
16
- store?: SourceStore;
17
- enabled?: () => boolean;
18
- fingerprint?: (path: string) => Promise<string | null>;
19
- /** Host-owned Desktop bindings may be read for exact reuse, never overwritten. */
20
- existingBinding?: (key: BindingKey) => Promise<Binding | null>;
21
- fetchImpl?: typeof fetch;
22
- signal?: AbortSignal;
23
- verificationTimeoutMs?: number;
24
- onProgress?: (bytes: number) => void;
25
- /** Test/host seams; the default is the authenticated v3 client. */
26
- api?: SourceApi;
27
- wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
28
- }
29
- export interface SourceRun {
30
- binding: Binding;
31
- manifest: Manifest;
32
- api: SourceApi;
33
- store: SourceStore;
34
- fetchImpl: typeof fetch;
35
- signal: AbortSignal;
36
- wait: (ms: number, signal?: AbortSignal) => Promise<void>;
37
- timeoutMs: number;
38
- onProgress: (bytes: number) => void;
39
- }