@hyperframes/share 0.0.0-stage → 0.9.21
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.
- package/LICENSE +190 -0
- package/README.md +51 -2
- package/dist/appOwnedPaths.d.mts +6 -0
- package/dist/fromReview.d.mts +15 -0
- package/dist/grantRefusal.d.mts +8 -0
- package/dist/host.d.mts +29 -0
- package/dist/hosts.d.ts +39 -0
- package/dist/index.d.mts +21 -0
- package/dist/projectLock.d.mts +17 -0
- package/dist/projectSnapshot.d.mts +20 -0
- package/dist/reviewAudience.d.mts +3 -0
- package/dist/reviewClient.d.mts +122 -0
- package/dist/reviewExport.d.mts +172 -0
- package/dist/reviewFiles.d.mts +43 -0
- package/dist/reviewLinkStates.d.mts +18 -0
- package/dist/reviewOpenCalls.d.mts +16 -0
- package/dist/reviewShare.d.mts +106 -0
- package/dist/reviewSwitch.d.mts +53 -0
- package/dist/reviewUpload.d.mts +16 -0
- package/dist/reviewVersionSentences.d.mts +2 -0
- package/dist/signedInRequest.d.mts +56 -0
- package/dist/storage.d.mts +20 -0
- package/dist/types/access.d.ts +12 -0
- package/dist/types/review.d.ts +59 -0
- package/dist/types/reviewWire.d.ts +200 -0
- package/dist/types/share.d.ts +119 -0
- package/dist/types/shareOpen.d.ts +96 -0
- package/package.json +112 -3
- package/scripts/copy-types.mjs +3 -0
- package/src/appOwnedPaths.mjs +27 -0
- package/src/fromReview.mjs +27 -0
- package/src/grantRefusal.mjs +28 -0
- package/src/host.mjs +46 -0
- package/src/hosts.d.ts +39 -0
- package/src/index.mjs +82 -0
- package/src/projectLock.mjs +120 -0
- package/src/projectSnapshot.mjs +120 -0
- package/src/reviewAudience.mjs +24 -0
- package/src/reviewClient.mjs +260 -0
- package/src/reviewExport.mjs +827 -0
- package/src/reviewFiles.mjs +98 -0
- package/src/reviewLinkStates.mjs +47 -0
- package/src/reviewOpenCalls.mjs +40 -0
- package/src/reviewShare.mjs +645 -0
- package/src/reviewSwitch.mjs +430 -0
- package/src/reviewUpload.mjs +98 -0
- package/src/reviewVersionSentences.mjs +48 -0
- package/src/signedInRequest.mjs +125 -0
- package/src/storage.mjs +116 -0
- package/src/types/access.d.ts +12 -0
- package/src/types/review.d.ts +59 -0
- package/src/types/reviewWire.d.ts +200 -0
- package/src/types/share.d.ts +119 -0
- package/src/types/shareOpen.d.ts +96 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Adding a version to a project's review page: the newest MP4 export goes up, then the server
|
|
3
|
+
// processes and screens it. Main owns the run, so closing the Share popover never stops it.
|
|
4
|
+
import { ShareBusyError, withProjectShareLock } from "./projectLock.mjs";
|
|
5
|
+
import { runFile } from "./host.mjs";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { stat } from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { ffBinary, latestExportFile, normalize, projectFingerprint } from "./host.mjs";
|
|
10
|
+
import { mkdirInside } from "./storage.mjs";
|
|
11
|
+
import {
|
|
12
|
+
isJsonObject,
|
|
13
|
+
isNullableSavedId,
|
|
14
|
+
isOptionalBoolean,
|
|
15
|
+
isOptionalSavedId,
|
|
16
|
+
isSavedId,
|
|
17
|
+
readJsonRecord,
|
|
18
|
+
SharingStateError,
|
|
19
|
+
writeJsonAtomic,
|
|
20
|
+
} from "./storage.mjs";
|
|
21
|
+
import { ReviewFailed } from "./reviewClient.mjs";
|
|
22
|
+
import { readFromReview, sharedFromReview } from "./fromReview.mjs";
|
|
23
|
+
import { reviewProjectId } from "./reviewShare.mjs";
|
|
24
|
+
import { fileSha256Base64, putFile } from "./reviewUpload.mjs";
|
|
25
|
+
import { versionSentence } from "./reviewVersionSentences.mjs";
|
|
26
|
+
|
|
27
|
+
/** @typedef {import("./reviewClient.mjs").ReviewClient} ReviewClient
|
|
28
|
+
* @typedef {import("./types/reviewWire.js").ExportDTO} ExportDTO @typedef {import("./types/reviewWire.js").ExportRequest} ExportRequest
|
|
29
|
+
* @typedef {import("./types/share.js").ReviewVersion} ReviewVersion */
|
|
30
|
+
|
|
31
|
+
/** @typedef {import("./types/share.js").ReviewExportProgress} ReviewExportProgress
|
|
32
|
+
* @typedef {{ duration: number, width: number, height: number }} VideoFacts
|
|
33
|
+
* @typedef {import("./fromReview.mjs").FromReview} FromReview
|
|
34
|
+
* @typedef {{ since: number, value?: "yes" | "no" }} ReviewSent
|
|
35
|
+
* @typedef {{ files: boolean, first: boolean, fromReview?: "yes" | "no" }} Added */
|
|
36
|
+
|
|
37
|
+
/** `pending`: the create body as sent and its video once answered, so a retry is the same; `snapshotRefused`: the
|
|
38
|
+
* server refused its snapshot, so it goes up video-only; `unanswered`: its create may have gone out without a
|
|
39
|
+
* definitive result; `reviewSent`: cleanup cutoff, with attribution only when the accepted attempt is
|
|
40
|
+
* known; `last`: the newest version.
|
|
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 */
|
|
45
|
+
|
|
46
|
+
/** `onAdded`: a version went ready, with whether it carries files and whether it is the page's first from here.
|
|
47
|
+
* @typedef {{ onProgress?: (progress: ReviewExportProgress | null) => void, onUploaded?: () => Promise<void>,
|
|
48
|
+
* open?: () => boolean, onAdded?: (added: Added) => void }} VersionHooks */
|
|
49
|
+
|
|
50
|
+
/** `projectId` overrides the recorded review project; `open`: a held video is watched only while the project is open.
|
|
51
|
+
* `filesForExport`: the synced snapshot a new version carries, null for
|
|
52
|
+
* none.
|
|
53
|
+
* @typedef {VersionHooks & { projectPath: string, title: string, client: ReviewClient, put?: typeof putFile,
|
|
54
|
+
* sleep?: (ms: number) => Promise<void>, probe?: (file: string) => Promise<VideoFacts | null>, now?: () => number,
|
|
55
|
+
* projectId?: string | null, filesForExport?: FilesForRender, fromReview?: FromReview | null }} VersionRequest */
|
|
56
|
+
|
|
57
|
+
/** The snapshot of `rendered`, the project as the export was rendered; null when the folder is not that render.
|
|
58
|
+
* @typedef {(projectPath: string, rendered: string | null) => Promise<import("./types/reviewWire.js").SnapshotRef | null>}
|
|
59
|
+
* FilesForRender */
|
|
60
|
+
|
|
61
|
+
const POLL_MS = 2_000;
|
|
62
|
+
/** Screening past this says it can take a while and is polled every SLOW_POLL_MS: staff review has no time limit. */
|
|
63
|
+
const SLOW_AFTER_MS = 2 * 60_000;
|
|
64
|
+
/** A server that says how screening stands: checking past this since the upload says it is taking longer than usual,
|
|
65
|
+
* and the watch keeps going as long again after it. */
|
|
66
|
+
const LATE_AFTER_MS = 15 * 60_000;
|
|
67
|
+
const SLOW_POLL_MS = 30_000;
|
|
68
|
+
/** Refusals the same video would get again: no Try again. */
|
|
69
|
+
const FINAL = new Set(["export_blocked", "content_rejected", "feature_unavailable"]);
|
|
70
|
+
/** Too big or too long: the same file is refused again, but a smaller or shorter export can go up. */
|
|
71
|
+
const TOO_BIG = new Set(["export_too_large", "export_too_long"]);
|
|
72
|
+
/** Longer than the backend's own limiter ever asks, and short enough that a laptop waking up checks again soon. */
|
|
73
|
+
const MAX_WAIT_MS = 5 * 60_000;
|
|
74
|
+
/** A run stops watching after this long; the video stays recorded, so the next press reads it back. */
|
|
75
|
+
const WATCH_MS = 15 * 60_000;
|
|
76
|
+
/** A held video waits on a person at HeyGen, which the sentence says can take up to a day: it is watched that long,
|
|
77
|
+
* at SLOW_POLL_MS, while its project stays open. */
|
|
78
|
+
const HELD_WATCH_MS = 24 * 60 * 60_000;
|
|
79
|
+
/** The backend's limits, checked before hashing or uploading anything. */
|
|
80
|
+
const MAX_BYTES = 2 * 1024 ** 3;
|
|
81
|
+
const MAX_SECONDS = 30 * 60;
|
|
82
|
+
|
|
83
|
+
const run$ = runFile;
|
|
84
|
+
const statePath = (/** @type {string} */ dir) =>
|
|
85
|
+
path.join(dir, ".hyperframes", "review-export.json");
|
|
86
|
+
|
|
87
|
+
/** The MP4's own picture size and length: the backend checks the upload against them, so never the canvas.
|
|
88
|
+
* @param {string} file @returns {Promise<VideoFacts | null>} */
|
|
89
|
+
async function probeVideo(file) {
|
|
90
|
+
try {
|
|
91
|
+
const args = [
|
|
92
|
+
"-v",
|
|
93
|
+
"error",
|
|
94
|
+
"-select_streams",
|
|
95
|
+
"v:0",
|
|
96
|
+
"-show_entries",
|
|
97
|
+
"stream=width,height:format=duration",
|
|
98
|
+
"-of",
|
|
99
|
+
"json",
|
|
100
|
+
"--",
|
|
101
|
+
file,
|
|
102
|
+
];
|
|
103
|
+
const { stdout } = await run$(ffBinary("ffprobe"), args);
|
|
104
|
+
return factsOf(stdout);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** ffprobe's JSON as facts, or null when it names no picture or no length. @param {string} stdout */
|
|
111
|
+
export function factsOf(stdout) {
|
|
112
|
+
const facts = JSON.parse(stdout);
|
|
113
|
+
const { width, height } = facts.streams?.[0] ?? {};
|
|
114
|
+
const duration = Number.parseFloat(facts.format?.duration);
|
|
115
|
+
return width > 0 && height > 0 && duration > 0 ? { duration, width, height } : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {unknown} upload */
|
|
119
|
+
const isSavedUpload = (upload) =>
|
|
120
|
+
isJsonObject(upload) &&
|
|
121
|
+
Number.isSafeInteger(upload.size_bytes) &&
|
|
122
|
+
isSavedId(upload.checksum_sha256) &&
|
|
123
|
+
upload.content_type === "video/mp4";
|
|
124
|
+
|
|
125
|
+
/** @param {unknown} snapshot */
|
|
126
|
+
const isOptionalSnapshot = (snapshot) =>
|
|
127
|
+
snapshot === undefined ||
|
|
128
|
+
(isJsonObject(snapshot) &&
|
|
129
|
+
isSavedId(snapshot.space_id) &&
|
|
130
|
+
isSavedId(snapshot.composition_id) &&
|
|
131
|
+
isSavedId(snapshot.snapshot_id));
|
|
132
|
+
|
|
133
|
+
/** @param {Record<string, unknown>} body */
|
|
134
|
+
const isSavedExportSource = (body) =>
|
|
135
|
+
body.source_revision_id === null &&
|
|
136
|
+
(body.source_fingerprint == null || typeof body.source_fingerprint === "string") &&
|
|
137
|
+
isOptionalSnapshot(body.snapshot);
|
|
138
|
+
|
|
139
|
+
/** @param {unknown} body */
|
|
140
|
+
function isSavedExportBody(body) {
|
|
141
|
+
if (!isJsonObject(body)) return false;
|
|
142
|
+
return (
|
|
143
|
+
isSavedId(body.client_operation_id) &&
|
|
144
|
+
typeof body.title_snapshot === "string" &&
|
|
145
|
+
[body.coordinate_width, body.coordinate_height, body.duration_seconds].every(Number.isFinite) &&
|
|
146
|
+
isJsonObject(body.render_settings) &&
|
|
147
|
+
isSavedUpload(body.local_upload) &&
|
|
148
|
+
isSavedExportSource(body)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @param {unknown} value @returns {value is NonNullable<VersionRecord["pending"]>} */
|
|
153
|
+
const isPending = (value) =>
|
|
154
|
+
isJsonObject(value) &&
|
|
155
|
+
isSavedExportBody(value.body) &&
|
|
156
|
+
isNullableSavedId(value.videoId) &&
|
|
157
|
+
isOptionalSavedId(value.projectId) &&
|
|
158
|
+
(value.checkingSince === undefined || Number.isFinite(value.checkingSince)) &&
|
|
159
|
+
isOptionalBoolean(value.snapshotRefused) &&
|
|
160
|
+
isOptionalBoolean(value.unanswered) &&
|
|
161
|
+
isOptionalReviewSent(value.reviewSent);
|
|
162
|
+
|
|
163
|
+
/** @param {unknown} sent */
|
|
164
|
+
const isOptionalReviewSent = (sent) =>
|
|
165
|
+
sent === undefined ||
|
|
166
|
+
(isJsonObject(sent) &&
|
|
167
|
+
Number.isFinite(sent.since) &&
|
|
168
|
+
(sent.value === undefined || sent.value === "yes" || sent.value === "no"));
|
|
169
|
+
|
|
170
|
+
/** @typedef {Omit<NonNullable<VersionRecord["last"]>, "fingerprint"> & {fingerprint?: string}} SavedLast */
|
|
171
|
+
|
|
172
|
+
/** @param {unknown} value @returns {value is SavedLast} */
|
|
173
|
+
function isLast(value) {
|
|
174
|
+
return (
|
|
175
|
+
isJsonObject(value) &&
|
|
176
|
+
(value.fingerprint === undefined || typeof value.fingerprint === "string") &&
|
|
177
|
+
isSavedId(value.versionId) &&
|
|
178
|
+
isSavedId(value.exportedAt) &&
|
|
179
|
+
isSavedId(value.projectId) &&
|
|
180
|
+
isOptionalBoolean(value.files) &&
|
|
181
|
+
(value.checksum === undefined || isSavedId(value.checksum))
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {Record<string, unknown>} saved
|
|
186
|
+
* @returns {saved is {pending?: VersionRecord["pending"], last?: SavedLast | null}} */
|
|
187
|
+
const isExportState = (saved) =>
|
|
188
|
+
("pending" in saved || "last" in saved) &&
|
|
189
|
+
(saved.pending == null || isPending(saved.pending)) &&
|
|
190
|
+
(saved.last == null || isLast(saved.last));
|
|
191
|
+
|
|
192
|
+
/** @param {string} dir @returns {Promise<VersionRecord>} */
|
|
193
|
+
async function readState(dir) {
|
|
194
|
+
const file = statePath(dir);
|
|
195
|
+
const saved = await readJsonRecord(file);
|
|
196
|
+
if (!saved) return { pending: null, last: null };
|
|
197
|
+
if (!isExportState(saved)) throw new SharingStateError(file);
|
|
198
|
+
const { last, pending } = saved;
|
|
199
|
+
return {
|
|
200
|
+
pending: pending ?? null,
|
|
201
|
+
last: typeof last?.fingerprint === "string" ? { ...last, fingerprint: last.fingerprint } : null,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @param {string} dir @param {VersionRecord} state */
|
|
206
|
+
async function writeState(dir, state) {
|
|
207
|
+
await mkdirInside(dir, ".hyperframes");
|
|
208
|
+
await writeJsonAtomic(statePath(dir), state);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** @typedef {{ sentence: string, final: boolean, exportHelps: boolean, held?: boolean }} Said
|
|
212
|
+
* @typedef {Said & { exportKey: string }} Failure */
|
|
213
|
+
/** @param {unknown} error @returns {Said} */
|
|
214
|
+
const said = (error) => ({
|
|
215
|
+
sentence: versionSentence(error),
|
|
216
|
+
final: error instanceof ReviewFailed && (FINAL.has(error.reason) || TOO_BIG.has(error.reason)),
|
|
217
|
+
exportHelps: error instanceof ReviewFailed && TOO_BIG.has(error.reason),
|
|
218
|
+
...(error instanceof ReviewFailed && error.reason === "held_for_review" && { held: true }),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** A failure found here, before the server: fixing the export fixes it. @param {string} sentence @returns {Said} */
|
|
222
|
+
const local = (sentence) => ({ sentence, final: false, exportHelps: false });
|
|
223
|
+
|
|
224
|
+
/** @type {Map<string, ReviewExportProgress>} */
|
|
225
|
+
const inFlight = new Map();
|
|
226
|
+
/** A running watch's wait, woken so its next read is now. @type {Map<string, () => void>} */
|
|
227
|
+
const wakers = new Map();
|
|
228
|
+
/** Runs woken while not waiting (a read was out): their next wait is skipped. @type {Set<string>} */
|
|
229
|
+
const woken = new Set();
|
|
230
|
+
/** The last run's sentence, kept only for the export it was about: a new export starts clean.
|
|
231
|
+
* @type {Map<string, Failure>} */
|
|
232
|
+
const failures = new Map();
|
|
233
|
+
|
|
234
|
+
/** Which export a failure was about: the newest export file and the project as it was rendered. @param {string} dir */
|
|
235
|
+
const exportKeyOf = (dir) => {
|
|
236
|
+
const latest = latestExportFile(dir);
|
|
237
|
+
return latest ? `${latest.path}\n${latest.fingerprint ?? ""}` : "";
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** Whether the newest export can go up now: there is one, it is MP4, and the project has not moved past it.
|
|
241
|
+
* @param {string} dir @param {string} now @returns {ReviewVersion["source"]} */
|
|
242
|
+
function sourceOf(dir, now) {
|
|
243
|
+
const latest = latestExportFile(dir);
|
|
244
|
+
if (!latest) return "none";
|
|
245
|
+
if (!/\.mp4$/i.test(latest.path)) return "not-mp4";
|
|
246
|
+
return latest.fingerprint === now ? "ready" : "stale";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** What the popover shows about versions. `last.current`: the project as it is now and, when the server says, still
|
|
250
|
+
* its latest ready version (a removed one, or a newer one from elsewhere, is not "on the page").
|
|
251
|
+
* @param {string} projectPath @param {{ latestReadyVersionId?: string | null }} [server] */
|
|
252
|
+
export async function reviewVersion(projectPath, server = {}) {
|
|
253
|
+
const [state, now, projectId] = await Promise.all([
|
|
254
|
+
readState(projectPath),
|
|
255
|
+
projectFingerprint(projectPath),
|
|
256
|
+
reviewProjectId(projectPath),
|
|
257
|
+
]);
|
|
258
|
+
const key = normalize(projectPath);
|
|
259
|
+
const last = state.last?.projectId === projectId ? state.last : null;
|
|
260
|
+
const onServer =
|
|
261
|
+
server.latestReadyVersionId === undefined || server.latestReadyVersionId === last?.versionId;
|
|
262
|
+
const failed = failures.get(key);
|
|
263
|
+
return {
|
|
264
|
+
last: last
|
|
265
|
+
? { exportedAt: last.exportedAt, current: last.fingerprint === now && onServer }
|
|
266
|
+
: null,
|
|
267
|
+
source: sourceOf(projectPath, now),
|
|
268
|
+
exporting: inFlight.get(key) ?? null,
|
|
269
|
+
...(failed?.exportKey === exportKeyOf(projectPath)
|
|
270
|
+
? {
|
|
271
|
+
failure: failed.sentence,
|
|
272
|
+
retryable: !failed.final,
|
|
273
|
+
exportHelps: failed.exportHelps,
|
|
274
|
+
...(failed.held && { held: true }),
|
|
275
|
+
}
|
|
276
|
+
: { failure: null }),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The newest MP4 and the body that adds it; a stale, non-MP4, too big or too long export never sends anything.
|
|
281
|
+
* @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) {
|
|
284
|
+
const latest = latestExportFile(dir);
|
|
285
|
+
if (!latest || sourceOf(dir, await projectFingerprint(dir)) !== "ready")
|
|
286
|
+
return { failure: local("Export an MP4 of the project as it is now first.") };
|
|
287
|
+
const { size } = await stat(latest.path);
|
|
288
|
+
if (size > MAX_BYTES) return { failure: said(new ReviewFailed(400, "export_too_large")) };
|
|
289
|
+
const facts = await probe(latest.path);
|
|
290
|
+
if (!facts) return { failure: local("Couldn't read this video. Export it again.") };
|
|
291
|
+
if (facts.duration > MAX_SECONDS)
|
|
292
|
+
return { failure: said(new ReviewFailed(400, "export_too_long")) };
|
|
293
|
+
/** @type {ExportRequest} */
|
|
294
|
+
const body = {
|
|
295
|
+
client_operation_id: `export-${randomUUID()}`,
|
|
296
|
+
title_snapshot: title.slice(0, 255),
|
|
297
|
+
coordinate_width: facts.width,
|
|
298
|
+
coordinate_height: facts.height,
|
|
299
|
+
duration_seconds: facts.duration,
|
|
300
|
+
render_settings: {},
|
|
301
|
+
source_revision_id: null,
|
|
302
|
+
// The project as rendered, which is what the version shows; never "now".
|
|
303
|
+
source_fingerprint: latest.fingerprint ?? null,
|
|
304
|
+
local_upload: {
|
|
305
|
+
size_bytes: size,
|
|
306
|
+
checksum_sha256: await fileSha256Base64(latest.path),
|
|
307
|
+
content_type: "video/mp4",
|
|
308
|
+
},
|
|
309
|
+
};
|
|
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
|
+
});
|
|
318
|
+
if (snapshot) body.snapshot = snapshot;
|
|
319
|
+
return { file: latest.path, body };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** @param {ExportRequest["snapshot"]} a @param {ExportRequest["snapshot"]} b */
|
|
323
|
+
const sameSnapshot = (a, b) =>
|
|
324
|
+
a?.space_id === b?.space_id &&
|
|
325
|
+
a?.composition_id === b?.composition_id &&
|
|
326
|
+
a?.snapshot_id === b?.snapshot_id;
|
|
327
|
+
|
|
328
|
+
/** A retry of the same file and project sends the pending body and its key unchanged. The snapshot is compared only
|
|
329
|
+
* while the server holds nothing: once it answered with a video, refused the snapshot, or may have made the video
|
|
330
|
+
* with no answer back, a different snapshot now would make one render two versions. A pending export of another
|
|
331
|
+
* server's project (the other backend's) is never resumed here; one saved before projectId was kept is this one's.
|
|
332
|
+
* @param {VersionRecord} state @param {ExportRequest} fresh @param {string} projectId */
|
|
333
|
+
function bodyToSend(state, fresh, projectId) {
|
|
334
|
+
const pending = state.pending;
|
|
335
|
+
if (pending && sameRender(pending, fresh, projectId) && reusableSnapshot(pending, fresh))
|
|
336
|
+
return pending;
|
|
337
|
+
return { body: fresh, videoId: null, projectId };
|
|
338
|
+
}
|
|
339
|
+
/** @param {NonNullable<VersionRecord["pending"]>} pending @param {ExportRequest} fresh @param {string} projectId */
|
|
340
|
+
function sameRender(pending, fresh, projectId) {
|
|
341
|
+
return (
|
|
342
|
+
(pending.projectId ?? projectId) === projectId &&
|
|
343
|
+
(pending.body.source_fingerprint ?? null) === fresh.source_fingerprint &&
|
|
344
|
+
pending.body.local_upload.checksum_sha256 === fresh.local_upload.checksum_sha256
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
/** @param {NonNullable<VersionRecord["pending"]>} pending @param {ExportRequest} fresh */
|
|
348
|
+
function reusableSnapshot(pending, fresh) {
|
|
349
|
+
if (pending.videoId || pending.snapshotRefused || pending.unanswered) return true;
|
|
350
|
+
return sameSnapshot(pending.body.snapshot, fresh.snapshot);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** No answer, or a server error, can follow a create the server made; a refusal says it made nothing.
|
|
354
|
+
* @param {unknown} error */
|
|
355
|
+
const unanswered = (error) =>
|
|
356
|
+
!(error instanceof ReviewFailed) ||
|
|
357
|
+
error.status === 0 ||
|
|
358
|
+
error.status === 408 ||
|
|
359
|
+
error.status >= 500;
|
|
360
|
+
|
|
361
|
+
/** The export as the server has it. A 422 `snapshot_mismatch` never costs the version: it goes up as video only, under
|
|
362
|
+
* a new key when no earlier send is unanswered.
|
|
363
|
+
* @param {Required<VersionRequest>} r @param {string} id @param {NonNullable<VersionRecord["pending"]>} pending
|
|
364
|
+
* @param {(next: NonNullable<VersionRecord["pending"]>) => Promise<void>} kept */
|
|
365
|
+
async function createdExport(r, id, pending, kept) {
|
|
366
|
+
if (pending.videoId) return { dto: await r.client.readExport(id, pending.videoId), pending };
|
|
367
|
+
try {
|
|
368
|
+
return await sendCreate(r, id, pending, kept);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const refused = error instanceof ReviewFailed && error.reason === "snapshot_mismatch";
|
|
371
|
+
if (!refused || !pending.body.snapshot || pending.unanswered) throw error;
|
|
372
|
+
console.error("[review] snapshot refused; the version goes up without files:", error);
|
|
373
|
+
const body = { ...pending.body, client_operation_id: `export-${randomUUID()}` };
|
|
374
|
+
delete body.snapshot;
|
|
375
|
+
const next = { ...pending, body, snapshotRefused: true };
|
|
376
|
+
await kept(next);
|
|
377
|
+
return sendCreate(r, id, next, kept);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @param {Required<VersionRequest>} r @param {string} id @param {NonNullable<VersionRecord["pending"]>} pending
|
|
382
|
+
* @param {(next: NonNullable<VersionRecord["pending"]>) => Promise<void>} kept */
|
|
383
|
+
async function sendCreate(r, id, pending, kept) {
|
|
384
|
+
const host = r.fromReview;
|
|
385
|
+
const { from_review: _, ...body } = pending.body;
|
|
386
|
+
const replay = pending.unanswered;
|
|
387
|
+
/** @param {ReviewSent} [reviewSent] */
|
|
388
|
+
const beforeSend = (reviewSent) => kept({ ...pending, unanswered: true, reviewSent });
|
|
389
|
+
/** @type {ReviewSent | undefined} */
|
|
390
|
+
let mark;
|
|
391
|
+
try {
|
|
392
|
+
if (!host && !replay) await beforeSend();
|
|
393
|
+
const dto = await r.client.createExport(
|
|
394
|
+
id,
|
|
395
|
+
host
|
|
396
|
+
? async () => {
|
|
397
|
+
mark = await readFromReview(host, r.projectPath);
|
|
398
|
+
// The auth loop only prepares a second send after a 401. A replay of an earlier run
|
|
399
|
+
// cannot assume that earlier send was refused.
|
|
400
|
+
if (!replay) await beforeSend({ since: mark.since });
|
|
401
|
+
return { ...body, ...(mark.value && { from_review: mark.value }) };
|
|
402
|
+
}
|
|
403
|
+
: pending.body,
|
|
404
|
+
);
|
|
405
|
+
const reviewSent = replay ? cleanupOnly(pending.reviewSent) : mark;
|
|
406
|
+
return { dto, pending: { ...pending, unanswered: false, reviewSent } };
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (!replay && !unanswered(error)) await kept(pending);
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** @param {ReviewSent | undefined} mark */
|
|
414
|
+
const cleanupOnly = (mark) => mark && { since: mark.since };
|
|
415
|
+
|
|
416
|
+
/** Bytes sent, reported when the whole percent moves, not per chunk (a 2 GiB file is ~32k chunks).
|
|
417
|
+
* @param {number} size @param {(progress: ReviewExportProgress) => void} report */
|
|
418
|
+
function uploadReporter(size, report) {
|
|
419
|
+
let shown = -1;
|
|
420
|
+
return (/** @type {number} */ sent) => {
|
|
421
|
+
const percent = Math.floor((sent / Math.max(size, 1)) * 100);
|
|
422
|
+
if (percent !== shown) {
|
|
423
|
+
shown = percent;
|
|
424
|
+
report({ step: "uploading", sent, total: size });
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Upload (once more with fresh instructions if the signed PUT expired), then complete.
|
|
430
|
+
* @param {Required<VersionRequest>} r @param {string} id @param {ExportDTO} created @param {string} file
|
|
431
|
+
* @param {number} size @param {(progress: ReviewExportProgress) => void} report @returns {Promise<ExportDTO>} */
|
|
432
|
+
async function upload(r, id, created, file, size, report) {
|
|
433
|
+
let current = created;
|
|
434
|
+
for (let tries = 0; current.status === "upload_pending" && current.upload && tries < 2; tries++) {
|
|
435
|
+
const sent = await r.put(current.upload, file, size, uploadReporter(size, report));
|
|
436
|
+
if (sent === "offline") throw new ReviewFailed(0, "offline");
|
|
437
|
+
if (sent === "refused") throw new ReviewFailed(502, "storage_refused");
|
|
438
|
+
if (sent === "sent") return r.client.completeExport(id, current.video_id);
|
|
439
|
+
current = await r.client.readExport(id, current.video_id);
|
|
440
|
+
}
|
|
441
|
+
// Two signed PUTs expired in a row: the slot is spent, and the next attempt starts over with a new key.
|
|
442
|
+
if (current.status === "upload_pending") throw new ReviewFailed(410, "upload_expired");
|
|
443
|
+
return current;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** @param {ExportDTO} dto */
|
|
447
|
+
const going = (dto) => dto.status === "processing" || dto.status === "screening";
|
|
448
|
+
|
|
449
|
+
/** How long after the upload a screening read keeps the watch going: a day for a held video, twice the late mark for
|
|
450
|
+
* one still checking, and none past WATCH_MS for an older server that does not say. @param {ExportDTO} current */
|
|
451
|
+
const screeningWatch = (current) => {
|
|
452
|
+
if (current.screening_state === "held") return HELD_WATCH_MS;
|
|
453
|
+
return current.screening_state ? 2 * LATE_AFTER_MS : 0;
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** Watches processing and screening (2 s, 30 s once slow or held) until done or out of time; `since` and `keep` let
|
|
457
|
+
* a restart resume the 15-minute clock. @param {Required<VersionRequest>} r @param {string} id
|
|
458
|
+
* @param {ExportDTO} current @param {number} size @param {(progress: ReviewExportProgress) => void} report
|
|
459
|
+
* @param {{ since: number | null, keep: (since: number) => Promise<void> }} clock @returns {Promise<ExportDTO>} */
|
|
460
|
+
async function watch(r, id, current, size, report, clock) {
|
|
461
|
+
let until = r.now() + WATCH_MS;
|
|
462
|
+
let { since } = clock;
|
|
463
|
+
let checkingSince = Infinity;
|
|
464
|
+
while (going(current)) {
|
|
465
|
+
if (current.status === "screening") {
|
|
466
|
+
checkingSince = Math.min(checkingSince, r.now());
|
|
467
|
+
if (since === null) await clock.keep((since = r.now()));
|
|
468
|
+
until = Math.max(until, since + screeningWatch(current));
|
|
469
|
+
}
|
|
470
|
+
const progress = progressOf(
|
|
471
|
+
current,
|
|
472
|
+
size,
|
|
473
|
+
r.now() - (since ?? Infinity),
|
|
474
|
+
r.now() - checkingSince,
|
|
475
|
+
);
|
|
476
|
+
report(progress);
|
|
477
|
+
if (r.now() >= until || !r.open()) return current;
|
|
478
|
+
current = await poll(r, id, current, until, progress.slow ? SLOW_POLL_MS : POLL_MS);
|
|
479
|
+
}
|
|
480
|
+
return current;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** One read as progress: late is counted from the upload, slow from this run's first screening read.
|
|
484
|
+
* @param {ExportDTO} current @param {number} size @param {number} sinceUpload @param {number} sinceChecking
|
|
485
|
+
* @returns {ReviewExportProgress} */
|
|
486
|
+
function progressOf(current, size, sinceUpload, sinceChecking) {
|
|
487
|
+
const checking = current.status === "screening";
|
|
488
|
+
const screening = checking ? current.screening_state : null;
|
|
489
|
+
const late = screening === "checking" && sinceUpload >= LATE_AFTER_MS;
|
|
490
|
+
/** @type {ReviewExportProgress} */
|
|
491
|
+
const progress = {
|
|
492
|
+
step: checking ? "checking" : "processing",
|
|
493
|
+
sent: size,
|
|
494
|
+
total: size,
|
|
495
|
+
slow: screening === "held" || sinceChecking >= SLOW_AFTER_MS,
|
|
496
|
+
};
|
|
497
|
+
if (screening) progress.screening = screening;
|
|
498
|
+
if (late) progress.late = true;
|
|
499
|
+
return progress;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Where checking is counted from: kept by an earlier run, else the upload completing in this one, kept with it.
|
|
503
|
+
* @param {Required<VersionRequest>} r @param {number | null} kept @param {boolean} completedNow
|
|
504
|
+
* @param {(since: number) => Promise<void>} keep */
|
|
505
|
+
async function checkingClock(r, kept, completedNow, keep) {
|
|
506
|
+
if (kept === null && completedNow) await keep((kept = r.now()));
|
|
507
|
+
return { since: kept, keep };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** What a run says of a video its watch left still going. @param {ExportDTO} done */
|
|
511
|
+
const stillGoing = (done) =>
|
|
512
|
+
said(
|
|
513
|
+
new ReviewFailed(
|
|
514
|
+
202,
|
|
515
|
+
done.status === "screening" && done.screening_state === "held"
|
|
516
|
+
? "held_for_review"
|
|
517
|
+
: "still_checking",
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
/** A transient answer: busy, a server error, or no connection (a laptop waking up). @param {unknown} error */
|
|
522
|
+
const transient = (error) =>
|
|
523
|
+
error instanceof ReviewFailed &&
|
|
524
|
+
(error.status === 0 || error.status === 429 || error.status >= 500);
|
|
525
|
+
|
|
526
|
+
/** The next read of `current`; a failed one is retried while the watch lasts and its project is open, else the
|
|
527
|
+
* video comes back as it was last read.
|
|
528
|
+
* @param {Required<VersionRequest>} r @param {string} id @param {ExportDTO} current @param {number} until
|
|
529
|
+
* @param {number} every @returns {Promise<ExportDTO>} */
|
|
530
|
+
async function poll(r, id, current, until, every) {
|
|
531
|
+
const key = normalize(r.projectPath);
|
|
532
|
+
for (let wait = every; ; ) {
|
|
533
|
+
if (!woken.delete(key))
|
|
534
|
+
await new Promise((done) => {
|
|
535
|
+
wakers.set(key, () => done(undefined));
|
|
536
|
+
void r.sleep(wait).then(done);
|
|
537
|
+
});
|
|
538
|
+
wakers.delete(key);
|
|
539
|
+
try {
|
|
540
|
+
return await r.client.readExport(id, current.video_id);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (!transient(error) || r.now() >= until) throw error;
|
|
543
|
+
if (!r.open()) return current;
|
|
544
|
+
wait = Math.min(
|
|
545
|
+
MAX_WAIT_MS,
|
|
546
|
+
Math.max(every, /** @type {ReviewFailed} */ (error).retryAfterMs ?? 0),
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** A refusal the same body would get again, or a spent upload, needs a new key next time.
|
|
553
|
+
* A refusal of a replay cannot settle an earlier unanswered create.
|
|
554
|
+
* @param {unknown} error @param {VersionRecord["pending"]} pending */
|
|
555
|
+
const spent = (error, pending) =>
|
|
556
|
+
(!pending?.unanswered || pending.videoId !== null) &&
|
|
557
|
+
error instanceof ReviewFailed &&
|
|
558
|
+
error.status >= 400 &&
|
|
559
|
+
error.status < 500 &&
|
|
560
|
+
![401, 408, 429].includes(error.status);
|
|
561
|
+
|
|
562
|
+
/** A video that is done: ready goes on the record as the newest version, anything else says why.
|
|
563
|
+
* @param {string} dir @param {VersionRecord} state @param {NonNullable<VersionRecord["pending"]>} pending
|
|
564
|
+
* @param {string} id @param {ExportDTO} done @param {FromReview | null} fromReview @returns {Promise<Said | null>} */
|
|
565
|
+
async function finish(dir, state, pending, id, done, fromReview) {
|
|
566
|
+
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 });
|
|
576
|
+
if (ready) await sharedFromReview(fromReview, dir, pending.reviewSent?.since);
|
|
577
|
+
return ready ? null : said(new ReviewFailed(200, done.error?.reason ?? ""));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** @param {Required<VersionRequest>} r @param {(progress: ReviewExportProgress) => void} report
|
|
581
|
+
* @returns {Promise<Said | null>} */
|
|
582
|
+
async function run(r, report) {
|
|
583
|
+
report({ step: "preparing", sent: 0, total: 0 });
|
|
584
|
+
const state = await readState(r.projectPath);
|
|
585
|
+
const source = await exportSource(r.projectPath, r.title, r.probe, r.filesForExport);
|
|
586
|
+
if ("failure" in source) return source.failure ?? null;
|
|
587
|
+
const id = r.projectId ?? (await reviewProjectId(r.projectPath));
|
|
588
|
+
if (!id) return local("Share the project first, then add a version.");
|
|
589
|
+
if (await alreadyUploaded(r.client, id, state, source.body)) return null;
|
|
590
|
+
return sendVersion(r, report, source, id, state);
|
|
591
|
+
}
|
|
592
|
+
/** @param {ReviewClient} client @param {string} id @param {VersionRecord} state @param {ExportRequest} body */
|
|
593
|
+
async function alreadyUploaded(client, id, state, body) {
|
|
594
|
+
const last = state.last;
|
|
595
|
+
if (state.pending || !last || last.projectId !== id) return false;
|
|
596
|
+
if (
|
|
597
|
+
last.fingerprint !== body.source_fingerprint ||
|
|
598
|
+
last.checksum !== body.local_upload.checksum_sha256
|
|
599
|
+
)
|
|
600
|
+
return false;
|
|
601
|
+
return (await client.project(id)).latest_ready_version_id === last.versionId;
|
|
602
|
+
}
|
|
603
|
+
/** @param {Required<VersionRequest>} r @param {(progress: ReviewExportProgress) => void} report
|
|
604
|
+
* @param {{file: string, body: ExportRequest}} source @param {string} id @param {VersionRecord} state */
|
|
605
|
+
async function sendVersion(r, report, source, id, state) {
|
|
606
|
+
const first = state.last?.projectId !== id;
|
|
607
|
+
let pending = bodyToSend(state, source.body, id);
|
|
608
|
+
await writeState(r.projectPath, (state = { ...state, pending }));
|
|
609
|
+
try {
|
|
610
|
+
const answer = await createdExport(r, id, pending, (next) =>
|
|
611
|
+
writeState(r.projectPath, (state = { ...state, pending: next })),
|
|
612
|
+
);
|
|
613
|
+
const created = answer.dto;
|
|
614
|
+
pending = answer.pending;
|
|
615
|
+
await writeState(
|
|
616
|
+
r.projectPath,
|
|
617
|
+
(state = { ...state, pending: { ...pending, videoId: created.video_id } }),
|
|
618
|
+
);
|
|
619
|
+
const size = pending.body.local_upload.size_bytes;
|
|
620
|
+
const uploaded = await upload(r, id, created, source.file, size, report);
|
|
621
|
+
if (uploaded.status !== "failed") await r.onUploaded();
|
|
622
|
+
const clock = await checkingClock(
|
|
623
|
+
r,
|
|
624
|
+
state.pending?.checkingSince ?? null,
|
|
625
|
+
created.status === "upload_pending" && going(uploaded),
|
|
626
|
+
async (since) => {
|
|
627
|
+
const kept = { ...pending, videoId: created.video_id, checkingSince: since };
|
|
628
|
+
await writeState(r.projectPath, (state = { ...state, pending: kept }));
|
|
629
|
+
},
|
|
630
|
+
);
|
|
631
|
+
const done = await watch(r, id, uploaded, size, report, clock);
|
|
632
|
+
return finishRun(r, state, pending, id, done, first);
|
|
633
|
+
} 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);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** @param {Required<VersionRequest>} r @param {VersionRecord} state
|
|
641
|
+
* @param {NonNullable<VersionRecord["pending"]>} pending @param {string} id @param {ExportDTO} done @param {boolean} first */
|
|
642
|
+
async function finishRun(r, state, pending, id, done, first) {
|
|
643
|
+
if (going(done)) return stillGoing(done);
|
|
644
|
+
const failed = await finish(r.projectPath, state, pending, id, done, r.fromReview);
|
|
645
|
+
if (!failed) {
|
|
646
|
+
const fromReview = pending.reviewSent?.value;
|
|
647
|
+
r.onAdded({ files: Boolean(pending.body.snapshot), first, ...(fromReview && { fromReview }) });
|
|
648
|
+
}
|
|
649
|
+
return failed;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Adds the newest export as a version and resolves once it is ready, failed, or out of watch, with the sentence when
|
|
653
|
+
* it did not go up. One run per project; a second asks nothing and resolves at once. A run onto another project keeps
|
|
654
|
+
* a failure from before the upload to itself: the recorded page is not the one it failed on.
|
|
655
|
+
* @param {VersionRequest} request @returns {Promise<string | null>} */
|
|
656
|
+
export async function addVersion(request) {
|
|
657
|
+
let uploaded = false;
|
|
658
|
+
const r = {
|
|
659
|
+
put: putFile,
|
|
660
|
+
sleep: (/** @type {number} */ ms) => new Promise((done) => setTimeout(done, ms)),
|
|
661
|
+
probe: probeVideo,
|
|
662
|
+
// Read through Date each time, so a walk can move main's clock under a run.
|
|
663
|
+
now: () => Date.now(),
|
|
664
|
+
onProgress: () => {},
|
|
665
|
+
open: () => true,
|
|
666
|
+
filesForExport: async () => null,
|
|
667
|
+
fromReview: null,
|
|
668
|
+
onAdded: () => {},
|
|
669
|
+
...request,
|
|
670
|
+
projectId: request.projectId ?? null,
|
|
671
|
+
onUploaded: async () => {
|
|
672
|
+
uploaded = true;
|
|
673
|
+
await request.onUploaded?.();
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
const key = normalize(r.projectPath);
|
|
677
|
+
if (inFlight.has(key)) return null;
|
|
678
|
+
failures.delete(key);
|
|
679
|
+
/** @param {ReviewExportProgress} progress */
|
|
680
|
+
const report = (progress) => (inFlight.set(key, progress), r.onProgress(progress));
|
|
681
|
+
try {
|
|
682
|
+
report({ step: "preparing", sent: 0, total: 0 });
|
|
683
|
+
const failure = await withProjectShareLock(r.projectPath, "export", () => run(r, report));
|
|
684
|
+
const kept = failure && (uploaded || !request.projectId);
|
|
685
|
+
if (kept) failures.set(key, { ...failure, exportKey: exportKeyOf(r.projectPath) });
|
|
686
|
+
return failure?.sentence ?? null;
|
|
687
|
+
} catch (error) {
|
|
688
|
+
console.error("[review] version failed:", error);
|
|
689
|
+
const sentence =
|
|
690
|
+
error instanceof SharingStateError ? error.message : "Couldn't add this version. Try again.";
|
|
691
|
+
failures.set(key, {
|
|
692
|
+
sentence,
|
|
693
|
+
final: false,
|
|
694
|
+
exportHelps: false,
|
|
695
|
+
exportKey: exportKeyOf(r.projectPath),
|
|
696
|
+
});
|
|
697
|
+
return sentence;
|
|
698
|
+
} finally {
|
|
699
|
+
inFlight.delete(key);
|
|
700
|
+
woken.delete(key);
|
|
701
|
+
r.onProgress(null);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** The popover opening reads a held video again: a running watch reads now, and one that ran out reads the video
|
|
706
|
+
* back once, so a video staff approved or refused since shows as it is. A read that finds it still held changes
|
|
707
|
+
* nothing; Check again starts a new watch.
|
|
708
|
+
* @param {string} projectPath @param {ReviewClient} client @param {FromReview | null} [fromReview] */
|
|
709
|
+
export async function recheckHeld(projectPath, client, fromReview = null) {
|
|
710
|
+
const key = normalize(projectPath);
|
|
711
|
+
if (inFlight.has(key)) return void (wakers.get(key) ?? (() => woken.add(key)))();
|
|
712
|
+
const failed = failures.get(key);
|
|
713
|
+
if (!failed?.held) return;
|
|
714
|
+
const { pending } = await readState(projectPath);
|
|
715
|
+
const id = pending?.projectId ?? (await reviewProjectId(projectPath));
|
|
716
|
+
if (!pending?.videoId || !id) return;
|
|
717
|
+
const done = await client.readExport(id, pending.videoId);
|
|
718
|
+
if (going(done)) return;
|
|
719
|
+
await whenExportIdle(projectPath, async () => {
|
|
720
|
+
if (inFlight.has(key) || failures.get(key) !== failed) return;
|
|
721
|
+
const state = await readState(projectPath);
|
|
722
|
+
const current = state.pending;
|
|
723
|
+
if (
|
|
724
|
+
!current ||
|
|
725
|
+
current.videoId !== pending.videoId ||
|
|
726
|
+
current.body.client_operation_id !== pending.body.client_operation_id ||
|
|
727
|
+
(current.projectId ?? (await reviewProjectId(projectPath))) !== id
|
|
728
|
+
) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const failure = await finish(projectPath, state, current, id, done, fromReview);
|
|
732
|
+
if (failure) failures.set(key, { ...failure, exportKey: failed.exportKey });
|
|
733
|
+
else failures.delete(key);
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** @param {string} dir @param {() => Promise<void>} run */
|
|
738
|
+
async function whenExportIdle(dir, run) {
|
|
739
|
+
try {
|
|
740
|
+
await withProjectShareLock(dir, "export", run, { timeoutMs: 0 });
|
|
741
|
+
} catch (error) {
|
|
742
|
+
if (!(error instanceof ShareBusyError)) throw error;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Settle a saved upload after its host exits, without competing with an active uploader.
|
|
747
|
+
* @param {string} projectPath @param {ReviewClient} client @param {FromReview | null} [fromReview] */
|
|
748
|
+
export async function refreshReviewExport(projectPath, client, fromReview = null) {
|
|
749
|
+
await whenExportIdle(projectPath, () => settleSavedExport(projectPath, client, fromReview));
|
|
750
|
+
}
|
|
751
|
+
/** @param {string} dir @param {ReviewClient} client @param {FromReview | null} fromReview */
|
|
752
|
+
async function settleSavedExport(dir, client, fromReview) {
|
|
753
|
+
const state = await readState(dir);
|
|
754
|
+
const pending = state.pending;
|
|
755
|
+
const id = await reviewProjectId(dir);
|
|
756
|
+
if (!pending?.videoId || !id || pending.projectId !== id) return;
|
|
757
|
+
const done = await client.readExport(id, pending.videoId);
|
|
758
|
+
if (going(done)) return;
|
|
759
|
+
const failure = await finish(dir, state, pending, id, done, fromReview);
|
|
760
|
+
const key = normalize(dir);
|
|
761
|
+
if (failure) failures.set(key, { ...failure, exportKey: exportKeyOf(dir) });
|
|
762
|
+
else failures.delete(key);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** The first share puts the newest export up with the link, when there is one to put and no version yet.
|
|
766
|
+
* @param {VersionRequest} request */
|
|
767
|
+
export async function addFirstVersion(request) {
|
|
768
|
+
const version = await reviewVersion(request.projectPath);
|
|
769
|
+
if (version.last || version.source !== "ready") return;
|
|
770
|
+
void addVersion(request);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** What the files line and a later attach read of a project's versions: the newest one this app put on the page, with
|
|
774
|
+
* whether the project is still as it was then; whether the newest export is the project now; whether a run is out.
|
|
775
|
+
* @param {string} dir */
|
|
776
|
+
export async function versionFacts(dir) {
|
|
777
|
+
const [state, now, projectId] = await Promise.all([
|
|
778
|
+
readState(dir),
|
|
779
|
+
projectFingerprint(dir),
|
|
780
|
+
reviewProjectId(dir),
|
|
781
|
+
]);
|
|
782
|
+
const last = state.last?.projectId === projectId ? state.last : null;
|
|
783
|
+
return {
|
|
784
|
+
last: last && { ...last, current: last.fingerprint === now },
|
|
785
|
+
source: sourceOf(dir, now),
|
|
786
|
+
running: inFlight.has(normalize(dir)),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The newest version this app put on the page, read without hashing the project; null while a run is out.
|
|
791
|
+
* @param {string} dir */
|
|
792
|
+
export async function versionOnPage(dir) {
|
|
793
|
+
const [state, projectId] = await Promise.all([readState(dir), reviewProjectId(dir)]);
|
|
794
|
+
if (inFlight.has(normalize(dir))) return null;
|
|
795
|
+
return state.last?.projectId === projectId ? state.last : null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Records that `versionId` carries files now, if it is still the last ready version.
|
|
799
|
+
* @param {string} dir @param {string} versionId @param {string} [projectId] */
|
|
800
|
+
export async function markFiles(dir, versionId, projectId) {
|
|
801
|
+
if (inFlight.has(normalize(dir))) return;
|
|
802
|
+
await withProjectShareLock(dir, "export", async () => {
|
|
803
|
+
const state = await readState(dir);
|
|
804
|
+
const { last } = state;
|
|
805
|
+
if (
|
|
806
|
+
!last ||
|
|
807
|
+
last.versionId !== versionId ||
|
|
808
|
+
(projectId !== undefined && last.projectId !== projectId)
|
|
809
|
+
) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
await writeState(dir, { ...state, last: { ...last, files: true } });
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** A pending operation is not the last ready version. @param {string} projectPath */
|
|
817
|
+
export async function pendingReviewExport(projectPath) {
|
|
818
|
+
const { pending } = await readState(projectPath);
|
|
819
|
+
if (!pending) return null;
|
|
820
|
+
return {
|
|
821
|
+
projectId: pending.projectId ?? null,
|
|
822
|
+
videoId: pending.videoId,
|
|
823
|
+
operationId: pending.body.client_operation_id,
|
|
824
|
+
fingerprint: pending.body.source_fingerprint ?? null,
|
|
825
|
+
checksum: pending.body.local_upload.checksum_sha256,
|
|
826
|
+
};
|
|
827
|
+
}
|