@vosjs/cli 0.59.1 → 0.60.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.
- package/README.md +3 -1
- package/dist/assetManifest-Y5FYTZW6.js +15 -0
- package/dist/{chunk-YC3LY4LW.js → chunk-LWHVZ5WI.js} +54 -539
- package/dist/chunk-LWHVZ5WI.js.map +1 -0
- package/dist/{chunk-CYU7LVPB.js → chunk-QL25OPZB.js} +455 -923
- package/dist/chunk-QL25OPZB.js.map +1 -0
- package/dist/chunk-WCF6KIOK.js +770 -0
- package/dist/chunk-WCF6KIOK.js.map +1 -0
- package/dist/chunk-YMVRTZ4L.js +719 -0
- package/dist/chunk-YMVRTZ4L.js.map +1 -0
- package/dist/cli.js +72 -16
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +15 -11
- package/dist/{run-TVTM3QJW.js → run-BJ77A4G6.js} +5 -3
- package/dist/run-BJ77A4G6.js.map +1 -0
- package/package.json +4 -4
- package/skills/VERSION +1 -1
- package/skills/vos-authoring/references/schema-reference.md +40 -2
- package/skills/vos-port/SKILL.md +3 -1
- package/skills/vos-port/references/elements-and-context.md +5 -0
- package/skills/vos-port/references/hyperframes.md +1 -1
- package/skills/vos-port/references/remotion.md +1 -1
- package/dist/chunk-CYU7LVPB.js.map +0 -1
- package/dist/chunk-YC3LY4LW.js.map +0 -1
- /package/dist/{run-TVTM3QJW.js.map → assetManifest-Y5FYTZW6.js.map} +0 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
MEDIA_HEAD_BYTES,
|
|
4
|
+
TAKE_MEDIA_EXTENSIONS,
|
|
5
|
+
apiJson,
|
|
6
|
+
extensionFor,
|
|
7
|
+
hostedAssetId,
|
|
8
|
+
localFile,
|
|
9
|
+
manifestRefs,
|
|
10
|
+
nameForType,
|
|
11
|
+
resolveMediaType
|
|
12
|
+
} from "./chunk-WCF6KIOK.js";
|
|
13
|
+
|
|
14
|
+
// src/plugin/assetManifest.ts
|
|
15
|
+
import {
|
|
16
|
+
existsSync as existsSync3,
|
|
17
|
+
readFileSync,
|
|
18
|
+
realpathSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync
|
|
21
|
+
} from "fs";
|
|
22
|
+
import { extname, join as join3, relative } from "path";
|
|
23
|
+
|
|
24
|
+
// src/plugin/media.ts
|
|
25
|
+
import { isKeyedOverlay } from "@vosjs/studio-core";
|
|
26
|
+
import { createHash } from "crypto";
|
|
27
|
+
import { createWriteStream, existsSync as existsSync2 } from "fs";
|
|
28
|
+
import {
|
|
29
|
+
mkdir as mkdir2,
|
|
30
|
+
open,
|
|
31
|
+
readFile as readFile2,
|
|
32
|
+
readdir,
|
|
33
|
+
rename as rename2,
|
|
34
|
+
rm as rm2,
|
|
35
|
+
stat,
|
|
36
|
+
writeFile as writeFile2
|
|
37
|
+
} from "fs/promises";
|
|
38
|
+
import { basename as basename2, dirname, join as join2 } from "path";
|
|
39
|
+
import { Readable } from "stream";
|
|
40
|
+
import { pipeline } from "stream/promises";
|
|
41
|
+
|
|
42
|
+
// src/plugin/take.ts
|
|
43
|
+
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
44
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
45
|
+
import { basename, join } from "path";
|
|
46
|
+
var RECORDING_NAME = "recording.webm";
|
|
47
|
+
function takeMediaFiles(dir, stem) {
|
|
48
|
+
return TAKE_MEDIA_EXTENSIONS.map((ext) => join(dir, `${stem}${ext}`)).filter(
|
|
49
|
+
(file) => existsSync(file)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
function takeMediaFile(dir, stem, fallback) {
|
|
53
|
+
return takeMediaFiles(dir, stem)[0] ?? join(dir, fallback);
|
|
54
|
+
}
|
|
55
|
+
var PREV_DOC_NAME = "doc.prev.json";
|
|
56
|
+
function takePaths(dir) {
|
|
57
|
+
const recording = takeMediaFile(dir, "recording", RECORDING_NAME);
|
|
58
|
+
return {
|
|
59
|
+
dir,
|
|
60
|
+
recording,
|
|
61
|
+
recordingName: basename(recording),
|
|
62
|
+
framesDir: join(dir, "frames"),
|
|
63
|
+
framesIndex: join(dir, "frames.json"),
|
|
64
|
+
cursor: join(dir, "cursor.json"),
|
|
65
|
+
meta: join(dir, "meta.json"),
|
|
66
|
+
actions: join(dir, "actions.json"),
|
|
67
|
+
doc: join(dir, "doc.json")
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function ensureTakeDir(dir) {
|
|
71
|
+
const p = takePaths(dir);
|
|
72
|
+
await mkdir(p.framesDir, { recursive: true });
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
async function dropScreencastFrames(p) {
|
|
76
|
+
if (!existsSync(p.recording) || statSync(p.recording).size === 0) return 0;
|
|
77
|
+
if (!existsSync(p.framesDir)) return 0;
|
|
78
|
+
let freed = 0;
|
|
79
|
+
for (const name of readdirSync(p.framesDir)) {
|
|
80
|
+
try {
|
|
81
|
+
freed += statSync(join(p.framesDir, name)).size;
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await rm(p.framesDir, { recursive: true, force: true });
|
|
86
|
+
await rm(p.framesIndex, { force: true });
|
|
87
|
+
return freed;
|
|
88
|
+
}
|
|
89
|
+
async function prepareReRecord(dir) {
|
|
90
|
+
const p = takePaths(dir);
|
|
91
|
+
const kept = [];
|
|
92
|
+
let prevDoc = false;
|
|
93
|
+
if (existsSync(p.doc)) {
|
|
94
|
+
await rename(p.doc, join(dir, PREV_DOC_NAME));
|
|
95
|
+
prevDoc = true;
|
|
96
|
+
kept.push(PREV_DOC_NAME);
|
|
97
|
+
}
|
|
98
|
+
if (existsSync(p.actions)) kept.push("actions.json");
|
|
99
|
+
if (existsSync(join(dir, "vos.json"))) kept.push("vos.json");
|
|
100
|
+
for (const stale of [
|
|
101
|
+
// Every container, not just the one `record` writes: a pulled take's
|
|
102
|
+
// footage may be mp4, and a survivor would outrank the new recording.
|
|
103
|
+
...takeMediaFiles(dir, "recording"),
|
|
104
|
+
...takeMediaFiles(dir, "mic"),
|
|
105
|
+
...takeMediaFiles(dir, "cam"),
|
|
106
|
+
p.cursor,
|
|
107
|
+
p.meta,
|
|
108
|
+
p.framesIndex,
|
|
109
|
+
p.framesDir,
|
|
110
|
+
join(dir, "stills"),
|
|
111
|
+
join(dir, "digest")
|
|
112
|
+
]) {
|
|
113
|
+
await rm(stale, { recursive: true, force: true });
|
|
114
|
+
}
|
|
115
|
+
return { prevDoc, kept };
|
|
116
|
+
}
|
|
117
|
+
async function readJson(file) {
|
|
118
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
119
|
+
}
|
|
120
|
+
async function writeJson(file, value, pretty = false) {
|
|
121
|
+
await writeFile(
|
|
122
|
+
file,
|
|
123
|
+
pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
async function loadTake(dir) {
|
|
127
|
+
const paths = takePaths(dir);
|
|
128
|
+
if (!existsSync(paths.meta)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${dir} is not a take directory (missing meta.json) \u2014 run record first`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const meta = await readJson(paths.meta);
|
|
134
|
+
const cursor = existsSync(paths.cursor) ? await readJson(paths.cursor) : [];
|
|
135
|
+
const actions = existsSync(paths.actions) ? await readJson(paths.actions) : null;
|
|
136
|
+
const doc = existsSync(paths.doc) ? await readJson(paths.doc) : null;
|
|
137
|
+
return { paths, cursor, meta, actions, doc };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/plugin/uploadAsset.ts
|
|
141
|
+
var SINGLE_SHOT_MAX_BYTES = 32 * 1024 * 1024;
|
|
142
|
+
var PART_ATTEMPTS = 3;
|
|
143
|
+
function planParts(totalBytes, partBytes) {
|
|
144
|
+
if (totalBytes <= 0 || partBytes <= 0) return [];
|
|
145
|
+
const parts = [];
|
|
146
|
+
for (let start = 0; start < totalBytes; start += partBytes) {
|
|
147
|
+
parts.push({
|
|
148
|
+
partNumber: parts.length + 1,
|
|
149
|
+
start,
|
|
150
|
+
end: Math.min(start + partBytes, totalBytes)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return parts;
|
|
154
|
+
}
|
|
155
|
+
function failed(what, r) {
|
|
156
|
+
const detail = typeof r.body.error === "string" ? r.body.error : "";
|
|
157
|
+
return new Error(`${what} (${r.status})${detail ? `: ${detail}` : ""}`);
|
|
158
|
+
}
|
|
159
|
+
function asAsset(body) {
|
|
160
|
+
return {
|
|
161
|
+
id: String(body.id),
|
|
162
|
+
url: String(body.url),
|
|
163
|
+
size: typeof body.size === "number" ? body.size : 0,
|
|
164
|
+
reused: body.reused === true
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function transient(status) {
|
|
168
|
+
return status >= 500 || status === 429;
|
|
169
|
+
}
|
|
170
|
+
var wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
171
|
+
async function uploadAsset(target, bytes, opts) {
|
|
172
|
+
if (bytes.length > SINGLE_SHOT_MAX_BYTES && opts.folderId) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`${opts.filename} is over ${SINGLE_SHOT_MAX_BYTES / 1024 / 1024} MB, and a file that large cannot be filed into a project on upload yet. Push it without --folder and reference it from a document, which keeps it`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return bytes.length > SINGLE_SHOT_MAX_BYTES ? uploadInParts(target, bytes, opts) : uploadWhole(target, bytes, opts);
|
|
178
|
+
}
|
|
179
|
+
async function uploadWhole(target, bytes, opts) {
|
|
180
|
+
const res = await apiJson(target.origin, "/api/assets/recording", {
|
|
181
|
+
method: "POST",
|
|
182
|
+
key: target.key,
|
|
183
|
+
headers: {
|
|
184
|
+
"Content-Type": opts.contentType,
|
|
185
|
+
"Content-Length": String(bytes.length),
|
|
186
|
+
"X-Filename": opts.filename,
|
|
187
|
+
"X-Content-Hash": opts.contentHash,
|
|
188
|
+
...opts.durationSeconds && opts.durationSeconds > 0 ? { "X-Content-Duration": opts.durationSeconds.toFixed(3) } : {},
|
|
189
|
+
...opts.folderId ? { "X-Folder-Id": opts.folderId } : {}
|
|
190
|
+
},
|
|
191
|
+
raw: bytes
|
|
192
|
+
});
|
|
193
|
+
if (res.status !== 201 && res.status !== 200)
|
|
194
|
+
throw failed("upload failed", res);
|
|
195
|
+
return asAsset(res.body);
|
|
196
|
+
}
|
|
197
|
+
async function uploadInParts(target, bytes, opts) {
|
|
198
|
+
const begun = await apiJson(
|
|
199
|
+
target.origin,
|
|
200
|
+
"/api/assets/recording/multipart",
|
|
201
|
+
{
|
|
202
|
+
method: "POST",
|
|
203
|
+
key: target.key,
|
|
204
|
+
body: {
|
|
205
|
+
filename: opts.filename,
|
|
206
|
+
contentType: opts.contentType,
|
|
207
|
+
size: bytes.length,
|
|
208
|
+
contentHash: opts.contentHash,
|
|
209
|
+
...opts.durationSeconds && opts.durationSeconds > 0 ? { durationSeconds: opts.durationSeconds } : {}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
if (begun.status !== 201 && begun.status !== 200) {
|
|
214
|
+
throw failed("upload failed", begun);
|
|
215
|
+
}
|
|
216
|
+
if (begun.body.reused === true || typeof begun.body.url === "string") {
|
|
217
|
+
return asAsset(begun.body);
|
|
218
|
+
}
|
|
219
|
+
const uploadId = String(begun.body.id);
|
|
220
|
+
const partBytes = Number(begun.body.partBytes);
|
|
221
|
+
if (!Number.isFinite(partBytes) || partBytes <= 0) {
|
|
222
|
+
throw new Error("upload failed: the platform returned no part size");
|
|
223
|
+
}
|
|
224
|
+
const parts = planParts(bytes.length, partBytes);
|
|
225
|
+
const etags = [];
|
|
226
|
+
try {
|
|
227
|
+
for (const part of parts) {
|
|
228
|
+
const chunk = bytes.subarray(part.start, part.end);
|
|
229
|
+
let sent = null;
|
|
230
|
+
for (let attempt = 1; attempt <= PART_ATTEMPTS; attempt++) {
|
|
231
|
+
try {
|
|
232
|
+
sent = await apiJson(
|
|
233
|
+
target.origin,
|
|
234
|
+
`/api/assets/recording/multipart/${uploadId}/parts/${part.partNumber}`,
|
|
235
|
+
{
|
|
236
|
+
method: "PUT",
|
|
237
|
+
key: target.key,
|
|
238
|
+
headers: {
|
|
239
|
+
"Content-Type": "application/octet-stream",
|
|
240
|
+
"Content-Length": String(chunk.length)
|
|
241
|
+
},
|
|
242
|
+
raw: chunk
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
} catch (networkError) {
|
|
246
|
+
if (attempt === PART_ATTEMPTS) throw networkError;
|
|
247
|
+
await wait(500 * 2 ** (attempt - 1));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (sent.status === 200) break;
|
|
251
|
+
if (!transient(sent.status) || attempt === PART_ATTEMPTS) {
|
|
252
|
+
throw failed(`part ${part.partNumber} failed`, sent);
|
|
253
|
+
}
|
|
254
|
+
await wait(500 * 2 ** (attempt - 1));
|
|
255
|
+
}
|
|
256
|
+
if (!sent || sent.status !== 200) {
|
|
257
|
+
throw new Error(`part ${part.partNumber} failed`);
|
|
258
|
+
}
|
|
259
|
+
etags.push({
|
|
260
|
+
partNumber: Number(sent.body.partNumber),
|
|
261
|
+
etag: String(sent.body.etag)
|
|
262
|
+
});
|
|
263
|
+
opts.onPart?.(etags.length, parts.length);
|
|
264
|
+
}
|
|
265
|
+
const sealed = await apiJson(
|
|
266
|
+
target.origin,
|
|
267
|
+
`/api/assets/recording/multipart/${uploadId}/complete`,
|
|
268
|
+
{ method: "POST", key: target.key, body: { parts: etags } }
|
|
269
|
+
);
|
|
270
|
+
if (sealed.status !== 201 && sealed.status !== 200) {
|
|
271
|
+
throw failed("upload failed", sealed);
|
|
272
|
+
}
|
|
273
|
+
return asAsset(sealed.body);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
await apiJson(
|
|
276
|
+
target.origin,
|
|
277
|
+
`/api/assets/recording/multipart/${uploadId}`,
|
|
278
|
+
{ method: "DELETE", key: target.key }
|
|
279
|
+
).catch(() => void 0);
|
|
280
|
+
throw err;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/plugin/media.ts
|
|
285
|
+
var SOURCE_MEDIA = [
|
|
286
|
+
{ key: "videoKey", stem: "recording", fallbackExt: ".webm" },
|
|
287
|
+
{ key: "micKey", stem: "mic", fallbackExt: ".webm" },
|
|
288
|
+
{ key: "camKey", stem: "cam", fallbackExt: ".webm" }
|
|
289
|
+
];
|
|
290
|
+
function isTakeRelativeKey(key) {
|
|
291
|
+
return !!key && !/^(https?:|blob:|data:|media:|\/\/)/.test(key) && !key.startsWith("/api/");
|
|
292
|
+
}
|
|
293
|
+
var takeRelativeFile = (key) => key.replace(/^\/+/, "");
|
|
294
|
+
function sourceSidecarRefs(doc, keep = isTakeRelativeKey) {
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const [field, where2] of [
|
|
297
|
+
["micKey", "mic track"],
|
|
298
|
+
["camKey", "cam track"]
|
|
299
|
+
]) {
|
|
300
|
+
const key = doc.source[field];
|
|
301
|
+
if (!key || !keep(key)) continue;
|
|
302
|
+
out.push({
|
|
303
|
+
where: where2,
|
|
304
|
+
key,
|
|
305
|
+
set: (next) => {
|
|
306
|
+
doc.source[field] = next;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
function docObjectRefs(doc, keep = isTakeRelativeKey) {
|
|
313
|
+
const out = [];
|
|
314
|
+
for (const clip of doc.objects ?? []) {
|
|
315
|
+
const asset = clip.asset;
|
|
316
|
+
if (asset.kind !== "gltf" || !keep(asset.key)) continue;
|
|
317
|
+
out.push({
|
|
318
|
+
where: `object ${clip.id}`,
|
|
319
|
+
key: asset.key,
|
|
320
|
+
set: (next) => {
|
|
321
|
+
asset.key = next;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
function docOverlayRefs(doc, keep = isTakeRelativeKey) {
|
|
328
|
+
const out = [];
|
|
329
|
+
for (const clip of doc.overlays ?? []) {
|
|
330
|
+
if (!isKeyedOverlay(clip) || !keep(clip.key)) continue;
|
|
331
|
+
out.push({
|
|
332
|
+
where: `overlay ${clip.id}`,
|
|
333
|
+
key: clip.key,
|
|
334
|
+
set: (next) => {
|
|
335
|
+
clip.key = next;
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
function docLayerRefs(doc, keep = isTakeRelativeKey) {
|
|
342
|
+
return [
|
|
343
|
+
...docOverlayRefs(doc, keep),
|
|
344
|
+
...docObjectRefs(doc, keep),
|
|
345
|
+
...docAudioRefs(doc, keep)
|
|
346
|
+
];
|
|
347
|
+
}
|
|
348
|
+
function docMediaRefs(doc, keep = isTakeRelativeKey) {
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const m of doc.media ?? []) {
|
|
351
|
+
if (keep(m.videoKey))
|
|
352
|
+
out.push({
|
|
353
|
+
where: `media ${m.id}`,
|
|
354
|
+
key: m.videoKey,
|
|
355
|
+
set: (next) => {
|
|
356
|
+
m.videoKey = next;
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
const mic = m.micKey;
|
|
360
|
+
if (mic && keep(mic))
|
|
361
|
+
out.push({
|
|
362
|
+
where: `media ${m.id} mic`,
|
|
363
|
+
key: mic,
|
|
364
|
+
set: (next) => {
|
|
365
|
+
m.micKey = next;
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
const cam = m.camKey;
|
|
369
|
+
if (cam && keep(cam))
|
|
370
|
+
out.push({
|
|
371
|
+
where: `media ${m.id} cam`,
|
|
372
|
+
key: cam,
|
|
373
|
+
set: (next) => {
|
|
374
|
+
m.camKey = next;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
out.push(...docOverlayRefs(doc, keep));
|
|
379
|
+
out.push(...docObjectRefs(doc, keep));
|
|
380
|
+
const mark = doc.endCard?.mark;
|
|
381
|
+
if (mark && keep(mark.key))
|
|
382
|
+
out.push({
|
|
383
|
+
where: "end card mark",
|
|
384
|
+
key: mark.key,
|
|
385
|
+
set: (next) => {
|
|
386
|
+
mark.key = next;
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
const bg = doc.frame.backgroundMedia;
|
|
390
|
+
if (bg && keep(bg.key))
|
|
391
|
+
out.push({
|
|
392
|
+
where: "background",
|
|
393
|
+
key: bg.key,
|
|
394
|
+
set: (next) => {
|
|
395
|
+
bg.key = next;
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
out.push(...docAudioRefs(doc, keep));
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
function docAudioRefs(doc, keep = isTakeRelativeKey) {
|
|
402
|
+
const out = [];
|
|
403
|
+
for (const clip of doc.audio ?? []) {
|
|
404
|
+
if (!keep(clip.key)) continue;
|
|
405
|
+
out.push({
|
|
406
|
+
where: `audio ${clip.id}`,
|
|
407
|
+
key: clip.key,
|
|
408
|
+
set: (next) => {
|
|
409
|
+
clip.key = next;
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
async function uploadDocRefs(refs, dir, target, log) {
|
|
416
|
+
for (const ref of refs) {
|
|
417
|
+
const file = join2(dir, takeRelativeFile(ref.key));
|
|
418
|
+
if (!existsSync2(file)) {
|
|
419
|
+
log(` ${ref.where}: ${ref.key} is not beside the document \u2014 left as is`);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const media = await readFile2(file);
|
|
423
|
+
const mediaHash = createHash("sha256").update(media).digest("hex");
|
|
424
|
+
const { type } = resolveMediaType({
|
|
425
|
+
head: media.subarray(0, MEDIA_HEAD_BYTES),
|
|
426
|
+
filename: file
|
|
427
|
+
});
|
|
428
|
+
const name = nameForType(basename2(file), type);
|
|
429
|
+
if (name !== basename2(file)) {
|
|
430
|
+
log(` ${ref.where}: ${basename2(file)} holds ${type} \u2014 sent as ${name}`);
|
|
431
|
+
}
|
|
432
|
+
let put;
|
|
433
|
+
try {
|
|
434
|
+
put = await uploadAsset(target, new Uint8Array(media), {
|
|
435
|
+
filename: name,
|
|
436
|
+
contentType: type,
|
|
437
|
+
contentHash: mediaHash
|
|
438
|
+
});
|
|
439
|
+
} catch (e) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`${ref.where} upload failed: ${e instanceof Error ? e.message : String(e)}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
ref.set(put.url);
|
|
445
|
+
log(
|
|
446
|
+
` ${ref.where}: ${ref.key} \u2192 asset ${put.id}${put.reused ? " (reused)" : ""}`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function assetIdOf(url) {
|
|
451
|
+
if (!url) return null;
|
|
452
|
+
const m = /\/api\/assets\/([A-Za-z0-9_-]+)\/file/.exec(url);
|
|
453
|
+
return m ? m[1] : null;
|
|
454
|
+
}
|
|
455
|
+
async function readMediaHead(file) {
|
|
456
|
+
const handle = await open(file, "r");
|
|
457
|
+
try {
|
|
458
|
+
const buf = Buffer.alloc(MEDIA_HEAD_BYTES);
|
|
459
|
+
const { bytesRead } = await handle.read(buf, 0, MEDIA_HEAD_BYTES, 0);
|
|
460
|
+
return new Uint8Array(buf.subarray(0, bytesRead));
|
|
461
|
+
} finally {
|
|
462
|
+
await handle.close();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
async function downloadMedia(ctx, url, target) {
|
|
466
|
+
const abs = /^https?:/.test(url) ? url : `${ctx.origin}${url}`;
|
|
467
|
+
const res = await fetch(abs, {
|
|
468
|
+
headers: ctx.key ? { authorization: `Bearer ${ctx.key}` } : {}
|
|
469
|
+
});
|
|
470
|
+
if (!res.ok || !res.body) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`download of ${target.describe} failed (${res.status})${res.status === 404 && target.notFoundHint ? ` \u2014 ${target.notFoundHint}` : ""}`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
const into = target.subdir ? join2(target.dir, target.subdir) : target.dir;
|
|
476
|
+
const partial = join2(into, `${target.stem}.part`);
|
|
477
|
+
await mkdir2(dirname(partial), { recursive: true });
|
|
478
|
+
try {
|
|
479
|
+
await pipeline(
|
|
480
|
+
Readable.fromWeb(res.body),
|
|
481
|
+
createWriteStream(partial)
|
|
482
|
+
);
|
|
483
|
+
const { type } = resolveMediaType({
|
|
484
|
+
head: await readMediaHead(partial),
|
|
485
|
+
declared: res.headers.get("content-type"),
|
|
486
|
+
filename: `${target.stem}${target.fallbackExt}`
|
|
487
|
+
});
|
|
488
|
+
const name = `${target.stem}${extensionFor(type) || target.fallbackExt}`;
|
|
489
|
+
await rename2(partial, join2(into, name));
|
|
490
|
+
const file = target.subdir ? `${target.subdir}/${name}` : name;
|
|
491
|
+
return { file, bytes: (await stat(join2(target.dir, file))).size };
|
|
492
|
+
} finally {
|
|
493
|
+
await rm2(partial, { force: true });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
async function pullMedia(ctx, dir, doc, log) {
|
|
497
|
+
const result = { downloaded: [], kept: [] };
|
|
498
|
+
for (const { key, stem, fallbackExt } of SOURCE_MEDIA) {
|
|
499
|
+
const url = doc.source[key];
|
|
500
|
+
const assetId = assetIdOf(url);
|
|
501
|
+
if (!assetId) continue;
|
|
502
|
+
const present = takeMediaFiles(dir, stem)[0];
|
|
503
|
+
if (present) {
|
|
504
|
+
result.kept.push(basename2(present));
|
|
505
|
+
doc.source[key] = basename2(present);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const got = await downloadMedia(ctx, url, {
|
|
509
|
+
dir,
|
|
510
|
+
stem,
|
|
511
|
+
fallbackExt,
|
|
512
|
+
describe: stem,
|
|
513
|
+
notFoundHint: "a private recording needs a content key of its owner"
|
|
514
|
+
});
|
|
515
|
+
result.downloaded.push({ file: got.file, assetId, bytes: got.bytes });
|
|
516
|
+
log(` ${got.file} \u2190 asset ${assetId} (${Math.round(got.bytes / 1024)} kB)`);
|
|
517
|
+
doc.source[key] = got.file;
|
|
518
|
+
}
|
|
519
|
+
for (const ref of docMediaRefs(doc, (k) => assetIdOf(k) !== null)) {
|
|
520
|
+
const assetId = assetIdOf(ref.key);
|
|
521
|
+
const existing = existsSync2(join2(dir, "media")) ? (await readdir(join2(dir, "media"))).find(
|
|
522
|
+
(f) => f === assetId || f.startsWith(`${assetId}.`)
|
|
523
|
+
) : void 0;
|
|
524
|
+
if (existing) {
|
|
525
|
+
const file = `media/${existing}`;
|
|
526
|
+
result.kept.push(file);
|
|
527
|
+
ref.set(file);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const got = await downloadMedia(ctx, ref.key, {
|
|
531
|
+
dir,
|
|
532
|
+
subdir: "media",
|
|
533
|
+
stem: assetId,
|
|
534
|
+
// A type with no extension here (an svg served as such) keeps the bare
|
|
535
|
+
// asset id, exactly as before.
|
|
536
|
+
fallbackExt: "",
|
|
537
|
+
describe: `${ref.where} (${ref.key})`
|
|
538
|
+
});
|
|
539
|
+
result.downloaded.push({ file: got.file, assetId, bytes: got.bytes });
|
|
540
|
+
log(
|
|
541
|
+
` ${got.file} \u2190 ${ref.where}, asset ${assetId} (${Math.round(got.bytes / 1024)} kB)`
|
|
542
|
+
);
|
|
543
|
+
ref.set(got.file);
|
|
544
|
+
}
|
|
545
|
+
const metaPath = join2(dir, "meta.json");
|
|
546
|
+
if (!existsSync2(metaPath)) await writeJson(metaPath, doc.source.meta, true);
|
|
547
|
+
const cursorPath = join2(dir, "cursor.json");
|
|
548
|
+
if (!existsSync2(cursorPath)) await writeJson(cursorPath, doc.source.cursor);
|
|
549
|
+
await writeFile2(join2(dir, "doc.json"), JSON.stringify(doc, null, 2));
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// src/plugin/assetManifest.ts
|
|
554
|
+
var isUrl = (ref) => /^(https?:|data:|blob:)/.test(ref);
|
|
555
|
+
var homeOrigins = (origin) => [
|
|
556
|
+
origin,
|
|
557
|
+
"https://vos.so",
|
|
558
|
+
"https://www.vos.so"
|
|
559
|
+
];
|
|
560
|
+
var NOT_UPLOADABLE = {
|
|
561
|
+
".woff2": "a font",
|
|
562
|
+
".woff": "a font",
|
|
563
|
+
".ttf": "a font",
|
|
564
|
+
".otf": "a font",
|
|
565
|
+
".hdr": "an HDR",
|
|
566
|
+
".exr": "an EXR",
|
|
567
|
+
".gltf": "a .gltf (export it as one .glb)"
|
|
568
|
+
};
|
|
569
|
+
var HOME_INDEX = ".hosted.json";
|
|
570
|
+
function cloneManifest(config) {
|
|
571
|
+
return JSON.parse(JSON.stringify(config.assets ?? {}));
|
|
572
|
+
}
|
|
573
|
+
function setRef(manifest, name, index, next) {
|
|
574
|
+
const decl = manifest[name];
|
|
575
|
+
if (index === null) decl.ref = next;
|
|
576
|
+
else decl.ref[index] = next;
|
|
577
|
+
}
|
|
578
|
+
var where = (name, index) => index === null ? `assets.${name}` : `assets.${name}[${index}]`;
|
|
579
|
+
async function uploadManifest(config, dir, target, log) {
|
|
580
|
+
const refs = manifestRefs(config);
|
|
581
|
+
if (refs.length === 0) return { uploaded: 0 };
|
|
582
|
+
const manifest = cloneManifest(config);
|
|
583
|
+
const local = [];
|
|
584
|
+
for (const { name, index, ref } of refs) {
|
|
585
|
+
const hosted = hostedAssetId(ref, homeOrigins(target.origin));
|
|
586
|
+
if (hosted) {
|
|
587
|
+
setRef(manifest, name, index, `asset:${hosted}`);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
if (isUrl(ref)) continue;
|
|
591
|
+
const kind = NOT_UPLOADABLE[extname(ref).toLowerCase()];
|
|
592
|
+
if (kind) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`${where(name, index)} names ${ref}: a push cannot upload ${kind} yet (pictures, video, sound and .glb models go). Host it at a URL and name the URL; the font and HDR catalogs are on assets.vos.so (GET /api/fonts)`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const found = localFile(ref, dir);
|
|
598
|
+
if ("refused" in found) {
|
|
599
|
+
throw new Error(`${where(name, index)} names ${ref}, ${found.refused}`);
|
|
600
|
+
}
|
|
601
|
+
local.push({
|
|
602
|
+
where: where(name, index),
|
|
603
|
+
// The upload helper reads keys against the directory.
|
|
604
|
+
key: relative(realpathSync(dir), found.file),
|
|
605
|
+
set: (url) => {
|
|
606
|
+
const id = hostedAssetId(url, homeOrigins(target.origin));
|
|
607
|
+
setRef(manifest, name, index, id ? `asset:${id}` : url);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
await uploadDocRefs(local, dir, target, log);
|
|
612
|
+
config.assets = manifest;
|
|
613
|
+
return { uploaded: local.length };
|
|
614
|
+
}
|
|
615
|
+
function localManifestRefs(config) {
|
|
616
|
+
return manifestRefs(config).filter(({ ref }) => !isUrl(ref) && !hostedAssetId(ref, homeOrigins(""))).map(({ name, index }) => where(name, index));
|
|
617
|
+
}
|
|
618
|
+
async function pullManifest(ctx, dir, config, log) {
|
|
619
|
+
const home = homeOrigins(ctx.origin);
|
|
620
|
+
const hosted = manifestRefs(config).filter(
|
|
621
|
+
({ ref }) => hostedAssetId(ref, home)
|
|
622
|
+
);
|
|
623
|
+
if (hosted.length === 0) return null;
|
|
624
|
+
const manifest = cloneManifest(config);
|
|
625
|
+
const indexPath = join3(dir, "assets", HOME_INDEX);
|
|
626
|
+
let index = {};
|
|
627
|
+
try {
|
|
628
|
+
const parsed = JSON.parse(readFileSync(indexPath, "utf8"));
|
|
629
|
+
if (parsed && typeof parsed === "object") {
|
|
630
|
+
index = parsed;
|
|
631
|
+
}
|
|
632
|
+
} catch {
|
|
633
|
+
}
|
|
634
|
+
const files = [];
|
|
635
|
+
for (const { name, index: at, ref } of hosted) {
|
|
636
|
+
const id = hostedAssetId(ref, home);
|
|
637
|
+
const stem = at === null ? name : `${name}-${at}`;
|
|
638
|
+
const known = Object.keys(index).find(
|
|
639
|
+
(f) => f === stem || f.startsWith(`${stem}.`)
|
|
640
|
+
);
|
|
641
|
+
let file;
|
|
642
|
+
if (known && index[known] === id && existsSync3(join3(dir, "assets", known))) {
|
|
643
|
+
file = `assets/${known}`;
|
|
644
|
+
} else {
|
|
645
|
+
if (known) {
|
|
646
|
+
rmSync(join3(dir, "assets", known), { force: true });
|
|
647
|
+
delete index[known];
|
|
648
|
+
}
|
|
649
|
+
const got = await downloadMedia(ctx, `/api/assets/${id}/file`, {
|
|
650
|
+
dir,
|
|
651
|
+
subdir: "assets",
|
|
652
|
+
stem,
|
|
653
|
+
fallbackExt: "",
|
|
654
|
+
describe: `${where(name, at)} (asset ${id})`,
|
|
655
|
+
notFoundHint: "a private file needs a content key of its owner"
|
|
656
|
+
});
|
|
657
|
+
file = got.file;
|
|
658
|
+
index[file.slice("assets/".length)] = id;
|
|
659
|
+
log(
|
|
660
|
+
` ${file} \u2190 ${where(name, at)}, asset ${id} (${Math.round(got.bytes / 1024)} kB)`
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
files.push(file);
|
|
664
|
+
setRef(manifest, name, at, `./${file}`);
|
|
665
|
+
}
|
|
666
|
+
writeFileSync(indexPath, JSON.stringify(index, null, 2) + "\n");
|
|
667
|
+
return { assets: manifest, files };
|
|
668
|
+
}
|
|
669
|
+
var HOSTED_FILE_LITERAL = /(?:asset:|\/api\/assets\/)([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})/g;
|
|
670
|
+
var FUNCTION_FIELDS = ["setup", "createContent", "createTimeline", "onFrame"];
|
|
671
|
+
function hostedLiteralWarnings(config) {
|
|
672
|
+
const out = [];
|
|
673
|
+
const scan = (label, src) => {
|
|
674
|
+
if (typeof src !== "string") return;
|
|
675
|
+
const seen = /* @__PURE__ */ new Set();
|
|
676
|
+
for (const m of src.matchAll(HOSTED_FILE_LITERAL)) {
|
|
677
|
+
if (seen.has(m[1])) continue;
|
|
678
|
+
seen.add(m[1]);
|
|
679
|
+
out.push(
|
|
680
|
+
`${label} names hosted file ${m[1]} in code. Declare it instead: "assets": { "name": { "ref": "asset:${m[1]}" } }, and read ctx.assets.name, so it loads on every render and follows a remix`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
for (const field of FUNCTION_FIELDS) scan(field, config[field]);
|
|
685
|
+
const stack = Array.isArray(config.stack) ? config.stack : [];
|
|
686
|
+
for (const entry of stack) {
|
|
687
|
+
for (const field of ["setup", "createContent", "onFrame"]) {
|
|
688
|
+
scan(`stack.${String(entry.id)}.${field}`, entry[field]);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export {
|
|
695
|
+
RECORDING_NAME,
|
|
696
|
+
takeMediaFile,
|
|
697
|
+
PREV_DOC_NAME,
|
|
698
|
+
takePaths,
|
|
699
|
+
ensureTakeDir,
|
|
700
|
+
dropScreencastFrames,
|
|
701
|
+
prepareReRecord,
|
|
702
|
+
readJson,
|
|
703
|
+
writeJson,
|
|
704
|
+
loadTake,
|
|
705
|
+
uploadAsset,
|
|
706
|
+
isTakeRelativeKey,
|
|
707
|
+
sourceSidecarRefs,
|
|
708
|
+
docLayerRefs,
|
|
709
|
+
docMediaRefs,
|
|
710
|
+
docAudioRefs,
|
|
711
|
+
uploadDocRefs,
|
|
712
|
+
readMediaHead,
|
|
713
|
+
pullMedia,
|
|
714
|
+
uploadManifest,
|
|
715
|
+
localManifestRefs,
|
|
716
|
+
pullManifest,
|
|
717
|
+
hostedLiteralWarnings
|
|
718
|
+
};
|
|
719
|
+
//# sourceMappingURL=chunk-YMVRTZ4L.js.map
|