video-editing 2.0.0 → 3.0.0
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 +36 -31
- package/dist/{chunk-SSXAUKGV.js → chunk-W4IIL2T2.js} +740 -801
- package/dist/cli.js +223 -123
- package/dist/index.d.ts +174 -138
- package/dist/index.js +43 -29
- package/package.json +2 -2
- package/skills/video-editing/SKILL.md +102 -74
- package/skills/video-editing/references/cli-reference.md +112 -72
|
@@ -1,126 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/ffmpeg.ts
|
|
4
|
-
import { spawn, spawnSync } from "child_process";
|
|
5
|
-
import { createWriteStream, mkdirSync } from "fs";
|
|
6
|
-
import { dirname } from "path";
|
|
7
|
-
function shquote(value) {
|
|
8
|
-
if (value.length === 0) return "''";
|
|
9
|
-
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
|
|
10
|
-
return "'" + value.replace(/'/g, `'"'"'`) + "'";
|
|
11
|
-
}
|
|
12
|
-
function shown(cmd) {
|
|
13
|
-
return cmd.map(shquote).join(" ");
|
|
14
|
-
}
|
|
15
|
-
var CommandError = class extends Error {
|
|
16
|
-
constructor(message, code, result) {
|
|
17
|
-
super(message);
|
|
18
|
-
this.code = code;
|
|
19
|
-
this.result = result;
|
|
20
|
-
this.name = "CommandError";
|
|
21
|
-
}
|
|
22
|
-
code;
|
|
23
|
-
result;
|
|
24
|
-
};
|
|
25
|
-
function run(cmd, opts = {}) {
|
|
26
|
-
const { capture = false, stdoutPath, cwd, check = true, echo = true } = opts;
|
|
27
|
-
if (echo) process.stderr.write(`+ ${shown(cmd)}
|
|
28
|
-
`);
|
|
29
|
-
return new Promise((resolve3, reject) => {
|
|
30
|
-
const [bin, ...args] = cmd;
|
|
31
|
-
if (!bin) {
|
|
32
|
-
reject(new Error("empty command"));
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
let fileStream;
|
|
36
|
-
const outChunks = [];
|
|
37
|
-
const errChunks = [];
|
|
38
|
-
const stdio = ["ignore", "pipe", "pipe"];
|
|
39
|
-
if (!capture && !stdoutPath) {
|
|
40
|
-
stdio[1] = "inherit";
|
|
41
|
-
stdio[2] = "inherit";
|
|
42
|
-
}
|
|
43
|
-
if (stdoutPath) {
|
|
44
|
-
mkdirSync(dirname(stdoutPath), { recursive: true });
|
|
45
|
-
fileStream = createWriteStream(stdoutPath, { encoding: "utf8" });
|
|
46
|
-
}
|
|
47
|
-
const child = spawn(bin, args, { cwd, stdio });
|
|
48
|
-
child.stdout?.on("data", (d) => {
|
|
49
|
-
if (fileStream) fileStream.write(d);
|
|
50
|
-
else if (capture) outChunks.push(d);
|
|
51
|
-
});
|
|
52
|
-
child.stderr?.on("data", (d) => {
|
|
53
|
-
if (fileStream) fileStream.write(d);
|
|
54
|
-
else if (capture) errChunks.push(d);
|
|
55
|
-
});
|
|
56
|
-
child.on("error", (err) => reject(err));
|
|
57
|
-
child.on("close", (code) => {
|
|
58
|
-
if (fileStream) fileStream.end();
|
|
59
|
-
const result = {
|
|
60
|
-
code: code ?? -1,
|
|
61
|
-
stdout: Buffer.concat(outChunks).toString("utf8"),
|
|
62
|
-
stderr: Buffer.concat(errChunks).toString("utf8")
|
|
63
|
-
};
|
|
64
|
-
if (check && result.code !== 0) {
|
|
65
|
-
if (capture) {
|
|
66
|
-
if (result.stdout) process.stderr.write(result.stdout + "\n");
|
|
67
|
-
if (result.stderr) process.stderr.write(result.stderr + "\n");
|
|
68
|
-
}
|
|
69
|
-
reject(new CommandError(`command failed with exit ${result.code}: ${shown(cmd)}`, result.code, result));
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
resolve3(result);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
function runSync(cmd, opts = {}) {
|
|
77
|
-
const { check = true, echo = true } = opts;
|
|
78
|
-
if (echo) process.stderr.write(`+ ${shown(cmd)}
|
|
79
|
-
`);
|
|
80
|
-
const [bin, ...args] = cmd;
|
|
81
|
-
const proc = spawnSync(bin, args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
82
|
-
const result = { code: proc.status ?? -1, stdout: proc.stdout ?? "", stderr: proc.stderr ?? "" };
|
|
83
|
-
if (check && result.code !== 0) {
|
|
84
|
-
throw new CommandError(`command failed with exit ${result.code}: ${shown(cmd)}`, result.code, result);
|
|
85
|
-
}
|
|
86
|
-
return result;
|
|
87
|
-
}
|
|
88
|
-
var FFPROBE_ENTRIES = "stream=index,codec_type,codec_name,width,height,r_frame_rate,avg_frame_rate,duration,color_transfer:stream_side_data=rotation:format=duration,size,format_name";
|
|
89
|
-
async function ffprobe(path) {
|
|
90
|
-
const res = await run(
|
|
91
|
-
["ffprobe", "-v", "error", "-show_entries", FFPROBE_ENTRIES, "-of", "json", path],
|
|
92
|
-
{ capture: true }
|
|
93
|
-
);
|
|
94
|
-
try {
|
|
95
|
-
return JSON.parse(res.stdout || "{}");
|
|
96
|
-
} catch (e) {
|
|
97
|
-
throw new Error(`ffprobe returned invalid JSON for ${path}: ${e.message}`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
function videoStream(probe) {
|
|
101
|
-
for (const s of probe.streams ?? []) {
|
|
102
|
-
if (s.codec_type === "video") return s;
|
|
103
|
-
}
|
|
104
|
-
throw new Error("no video stream found");
|
|
105
|
-
}
|
|
106
|
-
async function mediaDuration(path) {
|
|
107
|
-
const probe = await ffprobe(path);
|
|
108
|
-
const d = probe.format?.duration;
|
|
109
|
-
const n = d ? Number(d) : 0;
|
|
110
|
-
return Number.isFinite(n) ? n : 0;
|
|
111
|
-
}
|
|
112
|
-
function requireFfmpeg() {
|
|
113
|
-
for (const bin of ["ffmpeg", "ffprobe"]) {
|
|
114
|
-
const r = spawnSync(bin, ["-version"], { encoding: "utf8" });
|
|
115
|
-
if (r.error) {
|
|
116
|
-
throw new Error("ffmpeg and ffprobe must be on PATH");
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
3
|
// src/cloud/client.ts
|
|
122
|
-
import { createWriteStream
|
|
123
|
-
import { dirname
|
|
4
|
+
import { createWriteStream, mkdirSync, statSync, writeFileSync } from "fs";
|
|
5
|
+
import { dirname, join as pathJoin } from "path";
|
|
124
6
|
import { Readable } from "stream";
|
|
125
7
|
import { pipeline } from "stream/promises";
|
|
126
8
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
@@ -131,8 +13,9 @@ function firstNonEmpty(...vals) {
|
|
|
131
13
|
}
|
|
132
14
|
return void 0;
|
|
133
15
|
}
|
|
16
|
+
var DEFAULT_API_BASE = "https://clipready-usewhip.vercel.app";
|
|
134
17
|
function resolveApiBase(flag, env = process.env) {
|
|
135
|
-
return firstNonEmpty(flag, env.CLIPREADY_API_BASE, env.API_BASE);
|
|
18
|
+
return firstNonEmpty(flag, env.CLIPREADY_API_BASE, env.API_BASE) ?? DEFAULT_API_BASE;
|
|
136
19
|
}
|
|
137
20
|
function resolveApiKey(flag, env = process.env) {
|
|
138
21
|
return firstNonEmpty(flag, env.CLIPREADY_API_KEY, env.RUN_TOKEN);
|
|
@@ -186,9 +69,19 @@ function projectsUrl(base) {
|
|
|
186
69
|
function projectVideosUrl(base, projectId) {
|
|
187
70
|
return `${stripTrailingSlash(base)}/api/v1/projects/${encodeURIComponent(projectId)}/videos`;
|
|
188
71
|
}
|
|
72
|
+
function webVideoUrl(base, projectId, videoId) {
|
|
73
|
+
return `${stripTrailingSlash(base)}/projects/${encodeURIComponent(projectId)}/videos/${encodeURIComponent(videoId)}`;
|
|
74
|
+
}
|
|
189
75
|
function runUrl(base, runId) {
|
|
190
76
|
return `${stripTrailingSlash(base)}/api/v1/runs/${encodeURIComponent(runId)}`;
|
|
191
77
|
}
|
|
78
|
+
function multipartUrl(base, tail) {
|
|
79
|
+
const root = `${stripTrailingSlash(base)}/api/v1/uploads/multipart`;
|
|
80
|
+
return tail ? `${root}/${tail}` : root;
|
|
81
|
+
}
|
|
82
|
+
function multipartRelayUrl(base, uploadId, n, i, of) {
|
|
83
|
+
return `${multipartUrl(base, "relay")}?upload_id=${encodeURIComponent(uploadId)}&n=${n}&i=${i}&of=${of}`;
|
|
84
|
+
}
|
|
192
85
|
function videoUrl(base, videoId, tail) {
|
|
193
86
|
return `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/${tail}`;
|
|
194
87
|
}
|
|
@@ -199,6 +92,9 @@ function filesUrl(base, videoId, path) {
|
|
|
199
92
|
function transcribeUrl(base, videoId) {
|
|
200
93
|
return videoUrl(base, videoId, "transcribe");
|
|
201
94
|
}
|
|
95
|
+
function ingestUrl(base, videoId) {
|
|
96
|
+
return videoUrl(base, videoId, "ingest");
|
|
97
|
+
}
|
|
202
98
|
function compileUrl(base, videoId) {
|
|
203
99
|
return videoUrl(base, videoId, "compile");
|
|
204
100
|
}
|
|
@@ -279,6 +175,18 @@ function parseQaWaveform(data) {
|
|
|
279
175
|
}) : [];
|
|
280
176
|
return { url: o.url, words };
|
|
281
177
|
}
|
|
178
|
+
function parseQaInspect(data) {
|
|
179
|
+
const o = asRecord(data, "qa-inspect");
|
|
180
|
+
if (typeof o.url !== "string") throw new Error(`qa inspect response missing url: ${JSON.stringify(data)}`);
|
|
181
|
+
const cuts = Array.isArray(o.cuts) ? o.cuts.filter((c) => c !== null && typeof c === "object").map((c) => ({ start: Number(c.start ?? 0), end: Number(c.end ?? 0) })) : [];
|
|
182
|
+
return {
|
|
183
|
+
url: o.url,
|
|
184
|
+
tStart: Number(o.tStart ?? 0),
|
|
185
|
+
tEnd: Number(o.tEnd ?? 0),
|
|
186
|
+
cuts,
|
|
187
|
+
cached: o.cached === true
|
|
188
|
+
};
|
|
189
|
+
}
|
|
282
190
|
function parseFileList(data) {
|
|
283
191
|
const o = asRecord(data, "file-list");
|
|
284
192
|
const arr = o.files;
|
|
@@ -411,7 +319,10 @@ function extractErrorMessage(data) {
|
|
|
411
319
|
}
|
|
412
320
|
return void 0;
|
|
413
321
|
}
|
|
414
|
-
|
|
322
|
+
function backoffDelayMs(attempt) {
|
|
323
|
+
return 500 * 2 ** (attempt - 1);
|
|
324
|
+
}
|
|
325
|
+
var CloudClient = class _CloudClient {
|
|
415
326
|
constructor(cfg, opts = {}) {
|
|
416
327
|
this.cfg = cfg;
|
|
417
328
|
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
@@ -425,6 +336,61 @@ var CloudClient = class {
|
|
|
425
336
|
if (!this.cfg.videoId) throw new Error("missing video id \u2014 pass --video-id or set VIDEO_ID");
|
|
426
337
|
return this.cfg.videoId;
|
|
427
338
|
}
|
|
339
|
+
/** Same client (same fetch/timeout) bound to a video id — for flows that
|
|
340
|
+
* create the video mid-run (init). */
|
|
341
|
+
withVideoId(videoId) {
|
|
342
|
+
return new _CloudClient({ ...this.cfg, videoId }, { fetchImpl: this.fetchImpl, timeoutMs: this.timeoutMs });
|
|
343
|
+
}
|
|
344
|
+
/** The injected fetch — for helpers outside the class (multipart part PUTs)
|
|
345
|
+
* that need raw Response access (e.g. the ETag header). */
|
|
346
|
+
get fetch() {
|
|
347
|
+
return this.fetchImpl;
|
|
348
|
+
}
|
|
349
|
+
/** POST a JSON body to an absolute API URL with auth — public wrapper over
|
|
350
|
+
* the private transport for helpers living outside the class (multipart). */
|
|
351
|
+
async postJson(url, body) {
|
|
352
|
+
return this.request("POST", url, body);
|
|
353
|
+
}
|
|
354
|
+
/** POST raw binary bytes to an authenticated API URL (multipart relay
|
|
355
|
+
* sub-chunks). Content-Type application/octet-stream; JSON response. */
|
|
356
|
+
async postBinary(url, bytes, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
357
|
+
const ac = new AbortController();
|
|
358
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
359
|
+
try {
|
|
360
|
+
const res = await this.fetchImpl(url, {
|
|
361
|
+
method: "POST",
|
|
362
|
+
headers: {
|
|
363
|
+
Authorization: `Bearer ${this.cfg.credential}`,
|
|
364
|
+
Accept: "application/json",
|
|
365
|
+
"Content-Type": "application/octet-stream"
|
|
366
|
+
},
|
|
367
|
+
body: bytes,
|
|
368
|
+
signal: ac.signal
|
|
369
|
+
});
|
|
370
|
+
const text = await res.text();
|
|
371
|
+
let data = void 0;
|
|
372
|
+
if (text) {
|
|
373
|
+
try {
|
|
374
|
+
data = JSON.parse(text);
|
|
375
|
+
} catch {
|
|
376
|
+
data = text;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!res.ok) {
|
|
380
|
+
const detail = extractErrorMessage(data) ?? `${res.status} ${res.statusText}`;
|
|
381
|
+
throw new CloudHttpError(res.status, `POST ${url} failed: ${detail}`, data);
|
|
382
|
+
}
|
|
383
|
+
return data;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err instanceof CloudHttpError) throw err;
|
|
386
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
387
|
+
throw new Error(`request timed out after ${timeoutMs}ms: POST ${url}`);
|
|
388
|
+
}
|
|
389
|
+
throw err;
|
|
390
|
+
} finally {
|
|
391
|
+
clearTimeout(timer);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
428
394
|
async request(method, url, body) {
|
|
429
395
|
const ac = new AbortController();
|
|
430
396
|
const timer = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
@@ -491,6 +457,12 @@ var CloudClient = class {
|
|
|
491
457
|
});
|
|
492
458
|
return parseQaWaveform(data);
|
|
493
459
|
}
|
|
460
|
+
async qaInspect(args) {
|
|
461
|
+
const body = { kind: "inspect", start: args.start, end: args.end };
|
|
462
|
+
if (args.nFrames !== void 0) body.n_frames = args.nFrames;
|
|
463
|
+
const data = await this.request("POST", qaUrl(this.cfg.base, this.requireVideoId()), body);
|
|
464
|
+
return parseQaInspect(data);
|
|
465
|
+
}
|
|
494
466
|
// ---- artifacts ----------------------------------------------------------
|
|
495
467
|
async listArtifacts() {
|
|
496
468
|
const data = await this.request("GET", artifactsUrl(this.cfg.base, this.requireVideoId()));
|
|
@@ -512,28 +484,45 @@ var CloudClient = class {
|
|
|
512
484
|
return parseAssetCreate(data);
|
|
513
485
|
}
|
|
514
486
|
/** PUT raw bytes to a presigned upload URL. No Authorization header — the
|
|
515
|
-
* signed URL carries its own credentials and may be a different host.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
487
|
+
* signed URL carries its own credentials and may be a different host.
|
|
488
|
+
* Mid-stream network failures (connection resets, TLS record errors) and
|
|
489
|
+
* 5xx responses are retried with exponential backoff — the whole body is
|
|
490
|
+
* an in-memory buffer, so every attempt is a clean full replay. */
|
|
491
|
+
async upload(url, bytes, contentType, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS, maxAttempts = 5) {
|
|
492
|
+
let lastErr;
|
|
493
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
494
|
+
const ac = new AbortController();
|
|
495
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
496
|
+
try {
|
|
497
|
+
const res = await this.fetchImpl(url, {
|
|
498
|
+
method: "PUT",
|
|
499
|
+
headers: { "Content-Type": contentType },
|
|
500
|
+
body: bytes,
|
|
501
|
+
signal: ac.signal
|
|
502
|
+
});
|
|
503
|
+
if (res.ok) return;
|
|
504
|
+
const failure = new Error(`asset upload failed: HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
505
|
+
if (res.status < 500 || attempt === maxAttempts) throw failure;
|
|
506
|
+
lastErr = failure;
|
|
507
|
+
} catch (err) {
|
|
508
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
509
|
+
throw new Error(`asset upload timed out after ${timeoutMs}ms: ${url}`);
|
|
510
|
+
}
|
|
511
|
+
const cause = err.cause;
|
|
512
|
+
const detail = cause?.code ?? cause?.message;
|
|
513
|
+
const network = err instanceof TypeError;
|
|
514
|
+
if (!network || attempt === maxAttempts) {
|
|
515
|
+
throw err instanceof TypeError ? new Error(
|
|
516
|
+
`upload network failure after ${attempt} attempt${attempt === 1 ? "" : "s"}${detail ? ` (${detail})` : ""} PUTting ${bytes.length} bytes to ${new URL(url).host}`
|
|
517
|
+
) : err;
|
|
518
|
+
}
|
|
519
|
+
lastErr = err;
|
|
520
|
+
} finally {
|
|
521
|
+
clearTimeout(timer);
|
|
532
522
|
}
|
|
533
|
-
|
|
534
|
-
} finally {
|
|
535
|
-
clearTimeout(timer);
|
|
523
|
+
await new Promise((r) => setTimeout(r, backoffDelayMs(attempt)));
|
|
536
524
|
}
|
|
525
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
537
526
|
}
|
|
538
527
|
// ---- review session -----------------------------------------------------
|
|
539
528
|
async getReview() {
|
|
@@ -641,8 +630,22 @@ var CloudClient = class {
|
|
|
641
630
|
}
|
|
642
631
|
return { runId: o.run_id };
|
|
643
632
|
}
|
|
644
|
-
/** POST /videos/{id}/
|
|
645
|
-
|
|
633
|
+
/** POST /videos/{id}/ingest {stem?} → 202 {run_id}. Server-side probe + audio
|
|
634
|
+
* extraction + transcription + screening over the uploaded source. The stem
|
|
635
|
+
* names every derived artifact (transcripts/<stem>.json, audio/<stem>.wav) —
|
|
636
|
+
* omit it and the server defaults to "source". */
|
|
637
|
+
async ingestStart(stem) {
|
|
638
|
+
const body = stem !== void 0 && stem !== "" ? { stem } : {};
|
|
639
|
+
const data = await this.request("POST", ingestUrl(this.cfg.base, this.requireVideoId()), body);
|
|
640
|
+
const o = asRecord(data, "ingest");
|
|
641
|
+
if (typeof o.run_id !== "string" || o.run_id === "") {
|
|
642
|
+
throw new Error(`ingest response missing run_id: ${JSON.stringify(data)}`);
|
|
643
|
+
}
|
|
644
|
+
return { runId: o.run_id };
|
|
645
|
+
}
|
|
646
|
+
/** POST /videos/{id}/verify {mode?, window_s?} → 202 {run_id}. The server
|
|
647
|
+
* sources the preview audio itself — nothing is uploaded. */
|
|
648
|
+
async verifyStart(body = {}) {
|
|
646
649
|
const data = await this.request("POST", verifyUrl(this.cfg.base, this.requireVideoId()), body);
|
|
647
650
|
const o = asRecord(data, "verify");
|
|
648
651
|
if (typeof o.run_id !== "string" || o.run_id === "") {
|
|
@@ -686,14 +689,14 @@ async function downloadTo(url, dest, opts = {}) {
|
|
|
686
689
|
try {
|
|
687
690
|
const res = await f(url, { signal: ac.signal });
|
|
688
691
|
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
689
|
-
|
|
692
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
690
693
|
const body = res.body;
|
|
691
694
|
if (!body) {
|
|
692
695
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
693
696
|
writeFileSync(dest, buf);
|
|
694
697
|
return { path: dest, bytes: buf.length };
|
|
695
698
|
}
|
|
696
|
-
await pipeline(Readable.fromWeb(body),
|
|
699
|
+
await pipeline(Readable.fromWeb(body), createWriteStream(dest));
|
|
697
700
|
return { path: dest, bytes: statSync(dest).size };
|
|
698
701
|
} catch (err) {
|
|
699
702
|
if (err instanceof Error && err.name === "AbortError") {
|
|
@@ -733,10 +736,10 @@ async function pollRender(client, renderId, opts = {}) {
|
|
|
733
736
|
// src/cloud/skill.ts
|
|
734
737
|
import { cpSync, existsSync, readdirSync, rmSync, statSync as statSync2 } from "fs";
|
|
735
738
|
import { homedir } from "os";
|
|
736
|
-
import { dirname as
|
|
739
|
+
import { dirname as dirname2, join as join2, relative, resolve } from "path";
|
|
737
740
|
import { fileURLToPath } from "url";
|
|
738
741
|
function bundledSkillDir() {
|
|
739
|
-
const here =
|
|
742
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
740
743
|
const candidates = [
|
|
741
744
|
join2(here, "skills", "video-editing"),
|
|
742
745
|
join2(here, "..", "skills", "video-editing"),
|
|
@@ -770,13 +773,13 @@ function installSkill(opts = {}) {
|
|
|
770
773
|
}
|
|
771
774
|
|
|
772
775
|
// src/cloud/jobfile.ts
|
|
773
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
776
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
774
777
|
import { join as join3 } from "path";
|
|
775
778
|
function jobJsonPath(jobDir) {
|
|
776
779
|
return join3(jobDir, "job.json");
|
|
777
780
|
}
|
|
778
781
|
function writeJobFile(jobDir, job) {
|
|
779
|
-
|
|
782
|
+
mkdirSync2(jobDir, { recursive: true });
|
|
780
783
|
const path = jobJsonPath(jobDir);
|
|
781
784
|
writeFileSync2(path, JSON.stringify(job, null, 2) + "\n", "utf8");
|
|
782
785
|
return path;
|
|
@@ -792,7 +795,8 @@ function readJobFile(jobDir) {
|
|
|
792
795
|
project_id: typeof raw.project_id === "string" ? raw.project_id : "",
|
|
793
796
|
api_base: typeof raw.api_base === "string" ? raw.api_base : "",
|
|
794
797
|
stem: typeof raw.stem === "string" ? raw.stem : "source",
|
|
795
|
-
duration_s: typeof raw.duration_s === "number" ? raw.duration_s : 0
|
|
798
|
+
duration_s: typeof raw.duration_s === "number" ? raw.duration_s : 0,
|
|
799
|
+
...typeof raw.source_path === "string" && raw.source_path !== "" ? { source_path: raw.source_path } : {}
|
|
796
800
|
};
|
|
797
801
|
} catch {
|
|
798
802
|
return null;
|
|
@@ -805,184 +809,276 @@ function videoIdFromJob(jobDir, resolved) {
|
|
|
805
809
|
throw new Error(`missing video id \u2014 pass --video-id, set VIDEO_ID, or run \`init\` first (no ${jobJsonPath(jobDir)})`);
|
|
806
810
|
}
|
|
807
811
|
|
|
808
|
-
// src/cloud/
|
|
809
|
-
import {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
// src/probe.ts
|
|
818
|
-
function rotationDegrees(stream) {
|
|
819
|
-
for (const sideData of stream.side_data_list ?? []) {
|
|
820
|
-
if (sideData && "rotation" in sideData) {
|
|
821
|
-
const v = Number(sideData.rotation);
|
|
822
|
-
return Number.isFinite(v) ? Math.trunc(v) : 0;
|
|
812
|
+
// src/cloud/multipart.ts
|
|
813
|
+
import { open } from "fs/promises";
|
|
814
|
+
async function runPool(tasks, concurrency) {
|
|
815
|
+
let next = 0;
|
|
816
|
+
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
|
|
817
|
+
while (next < tasks.length) {
|
|
818
|
+
const task = tasks[next++];
|
|
819
|
+
if (task) await task();
|
|
823
820
|
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
821
|
+
});
|
|
822
|
+
await Promise.all(workers);
|
|
823
|
+
}
|
|
824
|
+
var MULTIPART_THRESHOLD_BYTES = 8 * 1024 * 1024;
|
|
825
|
+
var PART_CONCURRENCY = 3;
|
|
826
|
+
var PART_MAX_ATTEMPTS = 8;
|
|
827
|
+
var RELAY_DIRECT_ATTEMPTS = 3;
|
|
828
|
+
var RELAY_SUBCHUNK_BYTES = 3670016;
|
|
829
|
+
function jitteredBackoffMs(attempt) {
|
|
830
|
+
const base = Math.min(500 * 2 ** (attempt - 1), 8e3);
|
|
831
|
+
return Math.round(base / 2 + Math.random() * (base / 2));
|
|
832
|
+
}
|
|
833
|
+
function shouldMultipart(sizeOrBytes) {
|
|
834
|
+
const size = typeof sizeOrBytes === "number" ? sizeOrBytes : sizeOrBytes.length;
|
|
835
|
+
return size > MULTIPART_THRESHOLD_BYTES;
|
|
836
|
+
}
|
|
837
|
+
function bytesSource(bytes) {
|
|
838
|
+
return {
|
|
839
|
+
size: bytes.length,
|
|
840
|
+
readSlice: async (offset, len) => Buffer.from(bytes.subarray(offset, Math.min(offset + len, bytes.length)))
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
async function openFileSource(path) {
|
|
844
|
+
const fh = await open(path, "r");
|
|
845
|
+
const size = (await fh.stat()).size;
|
|
846
|
+
return {
|
|
847
|
+
size,
|
|
848
|
+
async readSlice(offset, len) {
|
|
849
|
+
const want = Math.min(len, Math.max(0, size - offset));
|
|
850
|
+
const buf = Buffer.allocUnsafe(want);
|
|
851
|
+
let done = 0;
|
|
852
|
+
while (done < want) {
|
|
853
|
+
const { bytesRead } = await fh.read(buf, done, want - done, offset + done);
|
|
854
|
+
if (bytesRead <= 0) throw new Error(`short read at byte ${offset + done} of ${path}`);
|
|
855
|
+
done += bytesRead;
|
|
856
|
+
}
|
|
857
|
+
return buf;
|
|
858
|
+
},
|
|
859
|
+
close: () => fh.close()
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
function asRecord2(data, ctx) {
|
|
863
|
+
if (data === null || typeof data !== "object") throw new Error(`unexpected ${ctx} response: ${JSON.stringify(data)}`);
|
|
864
|
+
return data;
|
|
865
|
+
}
|
|
866
|
+
function parsePartUrls(data, ctx) {
|
|
867
|
+
const o = asRecord2(data, ctx);
|
|
868
|
+
if (!Array.isArray(o.parts)) throw new Error(`${ctx} response missing parts[]: ${JSON.stringify(data)}`);
|
|
869
|
+
return o.parts.filter((p) => p !== null && typeof p === "object").map((p) => ({ n: Number(p.n ?? 0), url: String(p.url ?? "") })).filter((p) => Number.isInteger(p.n) && p.n >= 1 && p.url !== "");
|
|
831
870
|
}
|
|
832
|
-
function
|
|
833
|
-
const
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
return [height, width, rotation];
|
|
871
|
+
function parseMultipartCreate(data) {
|
|
872
|
+
const o = asRecord2(data, "multipart-create");
|
|
873
|
+
const uploadId = o.upload_id;
|
|
874
|
+
if (typeof uploadId !== "string" || uploadId === "") {
|
|
875
|
+
throw new Error(`multipart create response missing upload_id: ${JSON.stringify(data)}`);
|
|
838
876
|
}
|
|
839
|
-
|
|
877
|
+
const partSize = Number(o.part_size ?? 0);
|
|
878
|
+
if (!(partSize > 0)) throw new Error(`multipart create response missing part_size: ${JSON.stringify(data)}`);
|
|
879
|
+
return {
|
|
880
|
+
uploadId,
|
|
881
|
+
assetId: typeof o.asset_id === "string" && o.asset_id !== "" ? o.asset_id : void 0,
|
|
882
|
+
partSize,
|
|
883
|
+
parts: parsePartUrls(data, "multipart-create")
|
|
884
|
+
};
|
|
840
885
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
886
|
+
function isMultipartUnavailable(err) {
|
|
887
|
+
if (!(err instanceof CloudHttpError)) return false;
|
|
888
|
+
if (err.status === 404 || err.status === 405) return true;
|
|
889
|
+
if (err.status !== 503) return false;
|
|
890
|
+
const body = err.body;
|
|
891
|
+
return typeof body === "object" && body !== null && typeof body.error === "object" && body.error !== null ? body.error.code === "multipart_unavailable" : false;
|
|
892
|
+
}
|
|
893
|
+
function stripEtagQuotes(etag) {
|
|
894
|
+
return etag.trim().replace(/^"+|"+$/g, "");
|
|
895
|
+
}
|
|
896
|
+
function fmtMb(n) {
|
|
897
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
898
|
+
}
|
|
899
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
900
|
+
async function putPart(client, url, chunk, timeoutMs) {
|
|
901
|
+
const ac = new AbortController();
|
|
902
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
903
|
+
try {
|
|
904
|
+
return await client.fetch(url, { method: "PUT", body: chunk, signal: ac.signal });
|
|
905
|
+
} finally {
|
|
906
|
+
clearTimeout(timer);
|
|
860
907
|
}
|
|
861
|
-
let s = (rounded / m).toFixed(prec);
|
|
862
|
-
if (neg && Number(s) !== 0) s = "-" + s;
|
|
863
|
-
return s;
|
|
864
908
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
"-preset",
|
|
871
|
-
"medium",
|
|
872
|
-
"-crf",
|
|
873
|
-
"18",
|
|
874
|
-
"-pix_fmt",
|
|
875
|
-
"yuv420p",
|
|
876
|
-
"-c:a",
|
|
877
|
-
"aac",
|
|
878
|
-
"-b:a",
|
|
879
|
-
"192k",
|
|
880
|
-
"-movflags",
|
|
881
|
-
"+faststart"
|
|
882
|
-
];
|
|
883
|
-
async function ensureVerticalMaster(jobDir, source, opts = {}) {
|
|
884
|
-
const out = join4(jobDir, "vertical_src.mp4");
|
|
885
|
-
if (existsSync3(out) && !opts.force) return out;
|
|
886
|
-
const probe = await ffprobe(source);
|
|
887
|
-
const stream = videoStream(probe);
|
|
888
|
-
const [displayW, displayH, rotation] = displayedDimensions(stream);
|
|
889
|
-
const longEdge = Math.max(displayW, displayH);
|
|
890
|
-
let vf = displayH >= displayW && longEdge > 1920 ? "scale=-2:1920" : null;
|
|
891
|
-
if (displayW > displayH && longEdge > 1920) vf = "scale=1920:-2";
|
|
892
|
-
const cmd = ["ffmpeg", "-y", "-i", source];
|
|
893
|
-
if (vf) cmd.push("-vf", vf);
|
|
894
|
-
cmd.push(...X264_BAKE, out);
|
|
895
|
-
await run(cmd);
|
|
896
|
-
const outProbe = await ffprobe(out);
|
|
897
|
-
const outStream = videoStream(outProbe);
|
|
898
|
-
const outW = Math.trunc(Number(outStream.width ?? 0)) || 0;
|
|
899
|
-
const outH = Math.trunc(Number(outStream.height ?? 0)) || 0;
|
|
900
|
-
let retriedWith = null;
|
|
901
|
-
if (displayH > displayW && outW > outH) {
|
|
902
|
-
retriedWith = vf ? `transpose=1,${vf}` : "transpose=1";
|
|
903
|
-
await run(["ffmpeg", "-y", "-i", source, "-vf", retriedWith, ...X264_BAKE, out]);
|
|
904
|
-
}
|
|
905
|
-
writeFileSync3(
|
|
906
|
-
join4(jobDir, "master.json"),
|
|
907
|
-
JSON.stringify(
|
|
908
|
-
{
|
|
909
|
-
source,
|
|
910
|
-
master: out,
|
|
911
|
-
display_width: displayW,
|
|
912
|
-
display_height: displayH,
|
|
913
|
-
rotation,
|
|
914
|
-
scale_filter: vf,
|
|
915
|
-
retry_filter: retriedWith,
|
|
916
|
-
created: isoNow()
|
|
917
|
-
},
|
|
918
|
-
null,
|
|
919
|
-
2
|
|
920
|
-
) + "\n",
|
|
921
|
-
"utf8"
|
|
922
|
-
);
|
|
923
|
-
return out;
|
|
909
|
+
async function refreshPartUrl(client, uploadId, n) {
|
|
910
|
+
const data = await client.postJson(multipartUrl(client.cfg.base, "parts"), { upload_id: uploadId, ns: [n] });
|
|
911
|
+
const url = parsePartUrls(data, "multipart-parts").find((p) => p.n === n)?.url;
|
|
912
|
+
if (!url) throw new Error(`multipart parts refresh did not return a URL for part ${n}: ${JSON.stringify(data)}`);
|
|
913
|
+
return url;
|
|
924
914
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
915
|
+
async function relayOnePart(client, uploadId, n, chunk) {
|
|
916
|
+
const of = Math.max(1, Math.ceil(chunk.length / RELAY_SUBCHUNK_BYTES));
|
|
917
|
+
for (let i = 1; i <= of; i++) {
|
|
918
|
+
const sub = chunk.subarray((i - 1) * RELAY_SUBCHUNK_BYTES, Math.min(i * RELAY_SUBCHUNK_BYTES, chunk.length));
|
|
919
|
+
const data = await client.postBinary(multipartRelayUrl(client.cfg.base, uploadId, n, i, of), sub);
|
|
920
|
+
const o = data;
|
|
921
|
+
if (!o || o.ok !== true) {
|
|
922
|
+
throw new Error(`relay sub-chunk ${i}/${of} of part ${n} did not return {ok:true}: ${JSON.stringify(data)}`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
const done = asRecord2(
|
|
926
|
+
await client.postJson(multipartUrl(client.cfg.base, "relay-complete"), { upload_id: uploadId, n }),
|
|
927
|
+
"relay-complete"
|
|
928
|
+
);
|
|
929
|
+
if (typeof done.etag !== "string" || done.etag === "") {
|
|
930
|
+
throw new Error(`relay-complete for part ${n} returned no etag: ${JSON.stringify(done)}`);
|
|
931
|
+
}
|
|
932
|
+
return stripEtagQuotes(done.etag);
|
|
933
|
+
}
|
|
934
|
+
function isRelayUnavailable(err) {
|
|
935
|
+
return err instanceof CloudHttpError && (err.status === 404 || err.status === 405);
|
|
936
|
+
}
|
|
937
|
+
function transportDetail(err) {
|
|
938
|
+
const cause = err.cause;
|
|
939
|
+
return cause?.code ?? cause?.message;
|
|
940
|
+
}
|
|
941
|
+
async function uploadOnePart(client, uploadId, n, chunk, initialUrl, timeoutMs, backoffMs, diag) {
|
|
942
|
+
let url = initialUrl;
|
|
943
|
+
let lastErr;
|
|
944
|
+
let transportFailures = 0;
|
|
945
|
+
let relayUnavailable = false;
|
|
946
|
+
for (let attempt = 1; attempt <= PART_MAX_ATTEMPTS; attempt++) {
|
|
947
|
+
const useRelay = !relayUnavailable && transportFailures >= RELAY_DIRECT_ATTEMPTS;
|
|
948
|
+
const mode = useRelay ? "relay" : "direct";
|
|
949
|
+
const t0 = Date.now();
|
|
950
|
+
const report = (error) => {
|
|
951
|
+
if (!error && mode === "direct") return;
|
|
952
|
+
diag?.(
|
|
953
|
+
`part ${n} attempt ${attempt} mode ${mode} bytes ${chunk.length} elapsed ${Date.now() - t0}ms` + (error ? ` error ${error}` : " ok")
|
|
954
|
+
);
|
|
955
|
+
};
|
|
956
|
+
if (useRelay) {
|
|
957
|
+
try {
|
|
958
|
+
const etag = await relayOnePart(client, uploadId, n, chunk);
|
|
959
|
+
report();
|
|
960
|
+
return etag;
|
|
961
|
+
} catch (err) {
|
|
962
|
+
if (isRelayUnavailable(err)) {
|
|
963
|
+
relayUnavailable = true;
|
|
964
|
+
report("relay_unavailable");
|
|
965
|
+
} else {
|
|
966
|
+
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
967
|
+
report(err instanceof CloudHttpError ? `http_${err.status}` : lastErr.message.slice(0, 80));
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
} else {
|
|
971
|
+
try {
|
|
972
|
+
if (!url) url = await refreshPartUrl(client, uploadId, n);
|
|
973
|
+
const res = await putPart(client, url, chunk, timeoutMs);
|
|
974
|
+
if (res.ok) {
|
|
975
|
+
const etag = res.headers.get("etag");
|
|
976
|
+
if (!etag) throw new Error(`part ${n} uploaded but the response had no ETag header \u2014 cannot complete the multipart upload`);
|
|
977
|
+
report();
|
|
978
|
+
return stripEtagQuotes(etag);
|
|
979
|
+
}
|
|
980
|
+
lastErr = new Error(`part ${n}: HTTP ${res.status} ${res.statusText}`);
|
|
981
|
+
report(`http_${res.status}`);
|
|
982
|
+
if (res.status >= 400 && res.status < 500) url = void 0;
|
|
983
|
+
} catch (err) {
|
|
984
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
985
|
+
lastErr = new Error(`part ${n}: timed out after ${timeoutMs}ms`);
|
|
986
|
+
transportFailures += 1;
|
|
987
|
+
report("timeout");
|
|
988
|
+
} else if (err instanceof TypeError) {
|
|
989
|
+
const detail = transportDetail(err);
|
|
990
|
+
lastErr = new Error(`part ${n}: network failure${detail ? ` (${detail})` : ""}`);
|
|
991
|
+
transportFailures += 1;
|
|
992
|
+
report(detail ?? "fetch_failed");
|
|
993
|
+
} else {
|
|
994
|
+
throw err;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (attempt < PART_MAX_ATTEMPTS) await sleep2(backoffMs(attempt));
|
|
999
|
+
}
|
|
1000
|
+
throw new Error(
|
|
1001
|
+
`multipart upload failed at part ${n} (${chunk.length} bytes) after ${PART_MAX_ATTEMPTS} attempts: ${lastErr?.message ?? "unknown error"}`
|
|
1002
|
+
);
|
|
931
1003
|
}
|
|
932
|
-
async function
|
|
933
|
-
const source =
|
|
934
|
-
if (!
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
if (opts.verbose !== false) process.stderr.write(line + "\n");
|
|
940
|
-
};
|
|
941
|
-
await client.me();
|
|
942
|
-
const probe = await ffprobe(source);
|
|
943
|
-
const durationS = Number(probe.format?.duration ?? 0) || 0;
|
|
944
|
-
if (!(durationS > 0)) throw new Error(`could not read a duration from ${source} (is it a video?)`);
|
|
945
|
-
let uploadPath = source;
|
|
946
|
-
if (opts.master) {
|
|
947
|
-
log(" baking vertical 9:16 master (local ffmpeg)");
|
|
948
|
-
uploadPath = await ensureVerticalMaster(jobDir, source);
|
|
949
|
-
}
|
|
950
|
-
const title = opts.title ?? stem;
|
|
951
|
-
const { projectId } = await client.createProject(title);
|
|
952
|
-
log(` project: ${projectId}`);
|
|
953
|
-
const asset = await client.createSourceAsset({
|
|
954
|
-
contentType: contentTypeFor(uploadPath),
|
|
955
|
-
filename: parsePath(uploadPath).base
|
|
956
|
-
});
|
|
957
|
-
const bytes = readFileSync2(uploadPath);
|
|
958
|
-
log(` uploading ${parsePath(uploadPath).base} (${(bytes.length / (1024 * 1024)).toFixed(1)} MB)`);
|
|
959
|
-
await client.upload(asset.uploadUrl, bytes, contentTypeFor(uploadPath));
|
|
960
|
-
const video = await client.createVideo(projectId, { assetId: asset.assetId, title });
|
|
961
|
-
log(` video: ${video.videoId}`);
|
|
962
|
-
const job = {
|
|
963
|
-
video_id: video.videoId,
|
|
964
|
-
project_id: video.projectId,
|
|
965
|
-
api_base: client.cfg.base,
|
|
966
|
-
stem,
|
|
967
|
-
duration_s: durationS
|
|
1004
|
+
async function uploadMultipart(client, opts, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
1005
|
+
const source = opts.source ?? (opts.bytes !== void 0 ? bytesSource(opts.bytes) : void 0);
|
|
1006
|
+
if (!source) throw new Error("uploadMultipart: pass exactly one of bytes or source");
|
|
1007
|
+
const createBody = {
|
|
1008
|
+
kind: opts.kind,
|
|
1009
|
+
size: source.size,
|
|
1010
|
+
content_type: opts.contentType
|
|
968
1011
|
};
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1012
|
+
if (opts.filename !== void 0) createBody.filename = opts.filename;
|
|
1013
|
+
if (opts.videoId !== void 0) createBody.video_id = opts.videoId;
|
|
1014
|
+
if (opts.path !== void 0) createBody.path = opts.path;
|
|
1015
|
+
let session;
|
|
1016
|
+
try {
|
|
1017
|
+
session = parseMultipartCreate(await client.postJson(multipartUrl(client.cfg.base), createBody));
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
if (isMultipartUnavailable(err)) return "unavailable";
|
|
1020
|
+
throw err;
|
|
1021
|
+
}
|
|
1022
|
+
const abort = async () => {
|
|
1023
|
+
try {
|
|
1024
|
+
await client.postJson(multipartUrl(client.cfg.base, "abort"), { upload_id: session.uploadId });
|
|
1025
|
+
} catch {
|
|
1026
|
+
}
|
|
979
1027
|
};
|
|
1028
|
+
if (opts.kind === "asset" && !session.assetId) {
|
|
1029
|
+
await abort();
|
|
1030
|
+
throw new Error("multipart create (kind asset) returned no asset_id \u2014 cannot create the video afterwards");
|
|
1031
|
+
}
|
|
1032
|
+
const total = Math.max(1, Math.ceil(source.size / session.partSize));
|
|
1033
|
+
const urls = new Map(session.parts.map((p) => [p.n, p.url]));
|
|
1034
|
+
const marks = new Set([1, 2, 3, 4].map((k) => Math.ceil(total * k / 4)));
|
|
1035
|
+
const etags = new Array(total);
|
|
1036
|
+
let doneParts = 0;
|
|
1037
|
+
let doneBytes = 0;
|
|
1038
|
+
try {
|
|
1039
|
+
await runPool(
|
|
1040
|
+
Array.from({ length: total }, (_, i) => async () => {
|
|
1041
|
+
const n = i + 1;
|
|
1042
|
+
const offset = i * session.partSize;
|
|
1043
|
+
const chunk = await source.readSlice(offset, session.partSize);
|
|
1044
|
+
etags[i] = await uploadOnePart(
|
|
1045
|
+
client,
|
|
1046
|
+
session.uploadId,
|
|
1047
|
+
n,
|
|
1048
|
+
chunk,
|
|
1049
|
+
urls.get(n),
|
|
1050
|
+
timeoutMs,
|
|
1051
|
+
opts.backoffMs ?? jitteredBackoffMs,
|
|
1052
|
+
opts.log
|
|
1053
|
+
);
|
|
1054
|
+
doneParts += 1;
|
|
1055
|
+
doneBytes += chunk.length;
|
|
1056
|
+
if (opts.log && marks.has(doneParts)) {
|
|
1057
|
+
opts.log(`upload: ${doneParts}/${total} parts (${fmtMb(doneBytes)} / ${fmtMb(source.size)})`);
|
|
1058
|
+
}
|
|
1059
|
+
}),
|
|
1060
|
+
PART_CONCURRENCY
|
|
1061
|
+
);
|
|
1062
|
+
const completeBody = {
|
|
1063
|
+
upload_id: session.uploadId,
|
|
1064
|
+
parts: etags.map((etag, i) => ({ n: i + 1, etag }))
|
|
1065
|
+
};
|
|
1066
|
+
if (opts.kind === "jobfile") {
|
|
1067
|
+
if (opts.sha256 !== void 0) completeBody.sha256 = opts.sha256;
|
|
1068
|
+
completeBody.bytes = source.size;
|
|
1069
|
+
}
|
|
1070
|
+
await client.postJson(multipartUrl(client.cfg.base, "complete"), completeBody);
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
await abort();
|
|
1073
|
+
throw err;
|
|
1074
|
+
}
|
|
1075
|
+
return opts.kind === "asset" ? { assetId: session.assetId } : {};
|
|
980
1076
|
}
|
|
981
1077
|
|
|
982
1078
|
// src/cloud/files.ts
|
|
983
1079
|
import { createHash } from "crypto";
|
|
984
|
-
import { existsSync as
|
|
985
|
-
import { dirname as
|
|
1080
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1081
|
+
import { dirname as dirname3, join as join4, sep } from "path";
|
|
986
1082
|
var INLINE_MAX_BYTES = 5 * 1024 * 1024;
|
|
987
1083
|
function sha256Hex(bytes) {
|
|
988
1084
|
return createHash("sha256").update(bytes).digest("hex");
|
|
@@ -995,10 +1091,10 @@ function sameSha(a, b) {
|
|
|
995
1091
|
function vfsLocalPath(jobDir, path) {
|
|
996
1092
|
const parts = path.split("/").filter((p) => p !== "" && p !== "." && p !== "..");
|
|
997
1093
|
if (parts.length === 0) throw new Error(`bad VFS path: ${JSON.stringify(path)}`);
|
|
998
|
-
return
|
|
1094
|
+
return join4(jobDir, ...parts);
|
|
999
1095
|
}
|
|
1000
1096
|
function toVfsPath(jobDir, localPath) {
|
|
1001
|
-
const abs = localPath.startsWith(sep) ? localPath :
|
|
1097
|
+
const abs = localPath.startsWith(sep) ? localPath : join4(jobDir, localPath);
|
|
1002
1098
|
const rel = abs.startsWith(jobDir + sep) ? abs.slice(jobDir.length + 1) : localPath;
|
|
1003
1099
|
return rel.split(sep).join("/");
|
|
1004
1100
|
}
|
|
@@ -1036,18 +1132,18 @@ async function pullFiles(client, jobDir, opts = {}) {
|
|
|
1036
1132
|
const entry = byPath.get(path);
|
|
1037
1133
|
if (!entry) continue;
|
|
1038
1134
|
const dest = vfsLocalPath(jobDir, path);
|
|
1039
|
-
if (
|
|
1135
|
+
if (existsSync3(dest) && sameSha(sha256Hex(readFileSync2(dest)), entry.sha256)) {
|
|
1040
1136
|
const f2 = { path, localPath: dest, bytes: statSync3(dest).size, skipped: true };
|
|
1041
1137
|
pulled.push(f2);
|
|
1042
1138
|
opts.onFile?.(f2);
|
|
1043
1139
|
continue;
|
|
1044
1140
|
}
|
|
1045
1141
|
const got = await client.filesGet(path);
|
|
1046
|
-
|
|
1142
|
+
mkdirSync3(dirname3(dest), { recursive: true });
|
|
1047
1143
|
let bytes;
|
|
1048
1144
|
if (got.content !== void 0) {
|
|
1049
1145
|
const buf = Buffer.from(got.content, "utf8");
|
|
1050
|
-
|
|
1146
|
+
writeFileSync3(dest, buf);
|
|
1051
1147
|
bytes = buf.length;
|
|
1052
1148
|
if (got.sha256 && !sameSha(sha256Hex(buf), got.sha256)) {
|
|
1053
1149
|
throw new Error(`pulled ${path} but sha256 mismatch vs server (${got.sha256}) \u2014 refusing to trust the copy`);
|
|
@@ -1055,7 +1151,7 @@ async function pullFiles(client, jobDir, opts = {}) {
|
|
|
1055
1151
|
} else {
|
|
1056
1152
|
const dl = await client.download(got.downloadUrl, dest);
|
|
1057
1153
|
bytes = dl.bytes;
|
|
1058
|
-
if (got.sha256 && !sameSha(sha256Hex(
|
|
1154
|
+
if (got.sha256 && !sameSha(sha256Hex(readFileSync2(dest)), got.sha256)) {
|
|
1059
1155
|
throw new Error(`pulled ${path} but sha256 mismatch vs server (${got.sha256}) \u2014 refusing to trust the copy`);
|
|
1060
1156
|
}
|
|
1061
1157
|
}
|
|
@@ -1073,8 +1169,8 @@ async function pushFiles(client, jobDir, paths, opts = {}) {
|
|
|
1073
1169
|
for (const rel of paths) {
|
|
1074
1170
|
const vfsPath = toVfsPath(jobDir, rel);
|
|
1075
1171
|
const local = vfsLocalPath(jobDir, vfsPath);
|
|
1076
|
-
if (!
|
|
1077
|
-
const bytes =
|
|
1172
|
+
if (!existsSync3(local)) throw new Error(`files push: local file not found: ${local}`);
|
|
1173
|
+
const bytes = readFileSync2(local);
|
|
1078
1174
|
const sha = sha256Hex(bytes);
|
|
1079
1175
|
const existing = byPath.get(vfsPath);
|
|
1080
1176
|
if (existing && sameSha(sha, existing.sha256)) {
|
|
@@ -1094,9 +1190,7 @@ async function pushFiles(client, jobDir, paths, opts = {}) {
|
|
|
1094
1190
|
out.push(f);
|
|
1095
1191
|
opts.onFile?.(f);
|
|
1096
1192
|
} else {
|
|
1097
|
-
|
|
1098
|
-
await client.upload(presign.uploadUrl, bytes, contentType);
|
|
1099
|
-
await client.filesCommit(vfsPath);
|
|
1193
|
+
await uploadVfsPayload(client, vfsPath, bytes, contentType, { sha256: sha, log: opts.log });
|
|
1100
1194
|
const f = { path: vfsPath, bytes: bytes.length, skipped: false, mode: "presign" };
|
|
1101
1195
|
out.push(f);
|
|
1102
1196
|
opts.onFile?.(f);
|
|
@@ -1104,20 +1198,32 @@ async function pushFiles(client, jobDir, paths, opts = {}) {
|
|
|
1104
1198
|
}
|
|
1105
1199
|
return out;
|
|
1106
1200
|
}
|
|
1107
|
-
async function
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1201
|
+
async function uploadVfsPayload(client, vfsPath, bytes, contentType, opts = {}) {
|
|
1202
|
+
const sha = opts.sha256 ?? sha256Hex(bytes);
|
|
1203
|
+
if (shouldMultipart(bytes)) {
|
|
1204
|
+
const res = await uploadMultipart(client, {
|
|
1205
|
+
kind: "jobfile",
|
|
1206
|
+
bytes,
|
|
1207
|
+
contentType,
|
|
1208
|
+
videoId: client.cfg.videoId,
|
|
1209
|
+
path: vfsPath,
|
|
1210
|
+
sha256: sha,
|
|
1211
|
+
log: opts.log
|
|
1212
|
+
});
|
|
1213
|
+
if (res !== "unavailable") return;
|
|
1214
|
+
opts.log?.(" chunked upload unavailable on this server; using single-request upload");
|
|
1215
|
+
}
|
|
1216
|
+
const presign = await client.filesPresign({ path: vfsPath, contentType, bytes: bytes.length, sha256: sha });
|
|
1114
1217
|
await client.upload(presign.uploadUrl, bytes, contentType);
|
|
1115
1218
|
await client.filesCommit(vfsPath);
|
|
1116
1219
|
}
|
|
1220
|
+
async function uploadVfsBytes(client, vfsPath, bytes, contentType, log) {
|
|
1221
|
+
await uploadVfsPayload(client, vfsPath, bytes, contentType, { log });
|
|
1222
|
+
}
|
|
1117
1223
|
|
|
1118
1224
|
// src/cloud/runs.ts
|
|
1119
1225
|
var MAX_RUN_POLL_MS = 30 * 60 * 1e3;
|
|
1120
|
-
var
|
|
1226
|
+
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1121
1227
|
function formatRunEvent(ev) {
|
|
1122
1228
|
const p = ev.payload;
|
|
1123
1229
|
const message = p !== null && typeof p === "object" && typeof p.message === "string" ? p.message : "";
|
|
@@ -1126,7 +1232,7 @@ function formatRunEvent(ev) {
|
|
|
1126
1232
|
async function pollRun(client, runId, opts = {}) {
|
|
1127
1233
|
const intervalMs = opts.intervalMs ?? 5e3;
|
|
1128
1234
|
const maxMs = opts.maxMs ?? MAX_RUN_POLL_MS;
|
|
1129
|
-
const nap = opts.sleepImpl ??
|
|
1235
|
+
const nap = opts.sleepImpl ?? sleep3;
|
|
1130
1236
|
const now = opts.now ?? Date.now;
|
|
1131
1237
|
const start = now();
|
|
1132
1238
|
let seen = 0;
|
|
@@ -1147,115 +1253,136 @@ function assertRunCompleted(state, what) {
|
|
|
1147
1253
|
throw new Error(`${what} run ${state.id || ""} ${state.status}: ${detail}`);
|
|
1148
1254
|
}
|
|
1149
1255
|
|
|
1150
|
-
// src/cloud/
|
|
1151
|
-
import {
|
|
1152
|
-
import {
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
return
|
|
1256
|
+
// src/cloud/init.ts
|
|
1257
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3 } from "fs";
|
|
1258
|
+
import { join as join5, parse as parsePath, resolve as resolvePath } from "path";
|
|
1259
|
+
function sanitizeStem(name) {
|
|
1260
|
+
const safe = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1261
|
+
return safe === "" ? "source" : safe;
|
|
1262
|
+
}
|
|
1263
|
+
function ingestArtifactSet(stem) {
|
|
1264
|
+
return [`transcripts/${stem}.json`, "takes_packed.md", "flags.txt", "word_dump.txt", "coverage.json", "probe.json"];
|
|
1156
1265
|
}
|
|
1157
|
-
|
|
1158
|
-
const
|
|
1266
|
+
function readProbeDuration(jobDir) {
|
|
1267
|
+
const path = join5(jobDir, "probe.json");
|
|
1268
|
+
if (!existsSync4(path)) throw new Error(`ingest completed but probe.json was not pulled: ${path}`);
|
|
1269
|
+
let raw;
|
|
1159
1270
|
try {
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
{ capture: true, echo: false }
|
|
1164
|
-
);
|
|
1165
|
-
const audioS = await mediaDuration(wav);
|
|
1166
|
-
const videoS = await mediaDuration(media);
|
|
1167
|
-
if (Number.isFinite(audioS) && Number.isFinite(videoS) && Math.abs(audioS - videoS) > 0.15) {
|
|
1168
|
-
throw new Error(
|
|
1169
|
-
`source A/V clocks diverge: decoded audio is ${audioS.toFixed(3)}s but video is ${videoS.toFixed(3)}s (\u0394 ${(audioS - videoS).toFixed(3)}s). Word timestamps would drift against the picture by up to that much. Re-export the source with a clean muxer (e.g. \`ffmpeg -i in -c:v copy -af aresample=async=1 out.mp4\`) and retry.`
|
|
1170
|
-
);
|
|
1171
|
-
}
|
|
1172
|
-
return { bytes: readFileSync4(wav), durationS: audioS };
|
|
1173
|
-
} finally {
|
|
1174
|
-
rmSync2(tmp, { recursive: true, force: true });
|
|
1271
|
+
raw = JSON.parse(readFileSync3(path, "utf8"));
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
throw new Error(`probe.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
1175
1274
|
}
|
|
1275
|
+
const durationS = Number(raw?.duration_s ?? NaN);
|
|
1276
|
+
if (!(durationS > 0)) throw new Error(`probe.json has no usable duration_s: ${JSON.stringify(raw)}`);
|
|
1277
|
+
return durationS;
|
|
1176
1278
|
}
|
|
1177
|
-
|
|
1178
|
-
const
|
|
1279
|
+
function contentTypeFor(path) {
|
|
1280
|
+
const ext = parsePath(path).ext.toLowerCase();
|
|
1281
|
+
const map = { ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo" };
|
|
1282
|
+
return map[ext] ?? "video/mp4";
|
|
1283
|
+
}
|
|
1284
|
+
async function initCloudJob(client, opts) {
|
|
1285
|
+
const source = resolvePath(opts.source);
|
|
1286
|
+
if (!existsSync4(source)) throw new Error(`source not found: ${source}`);
|
|
1287
|
+
const rawName = parsePath(source).name;
|
|
1288
|
+
const stem = sanitizeStem(rawName);
|
|
1289
|
+
const jobDir = resolvePath(opts.jobDir ?? join5(process.cwd(), `job_${stem}`));
|
|
1290
|
+
mkdirSync4(jobDir, { recursive: true });
|
|
1179
1291
|
const log = (line) => {
|
|
1180
|
-
if (verbose) process.stderr.write(line + "\n");
|
|
1292
|
+
if (opts.verbose !== false) process.stderr.write(line + "\n");
|
|
1181
1293
|
};
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
const
|
|
1185
|
-
log(`
|
|
1186
|
-
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
});
|
|
1194
|
-
|
|
1195
|
-
|
|
1294
|
+
await client.me();
|
|
1295
|
+
const title = opts.title ?? rawName;
|
|
1296
|
+
const { projectId } = await client.createProject(title);
|
|
1297
|
+
log(` project: ${projectId}`);
|
|
1298
|
+
const contentType = contentTypeFor(source);
|
|
1299
|
+
const filename = parsePath(source).base;
|
|
1300
|
+
let assetId;
|
|
1301
|
+
let uploadedBytes = 0;
|
|
1302
|
+
const fileSrc = await openFileSource(source);
|
|
1303
|
+
try {
|
|
1304
|
+
uploadedBytes = fileSrc.size;
|
|
1305
|
+
log(` uploading ${filename} (${(fileSrc.size / (1024 * 1024)).toFixed(1)} MB)`);
|
|
1306
|
+
if (shouldMultipart(fileSrc.size)) {
|
|
1307
|
+
const mp = await uploadMultipart(client, { kind: "asset", source: fileSrc, contentType, filename, log });
|
|
1308
|
+
if (mp === "unavailable") {
|
|
1309
|
+
log(" chunked upload unavailable on this server; using single-request upload");
|
|
1310
|
+
} else {
|
|
1311
|
+
assetId = mp.assetId;
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (!assetId) {
|
|
1315
|
+
const bytes = await fileSrc.readSlice(0, fileSrc.size);
|
|
1316
|
+
const asset = await client.createSourceAsset({ contentType, filename });
|
|
1317
|
+
await client.upload(asset.uploadUrl, bytes, contentType);
|
|
1318
|
+
assetId = asset.assetId;
|
|
1319
|
+
}
|
|
1320
|
+
} finally {
|
|
1321
|
+
await fileSrc.close();
|
|
1322
|
+
}
|
|
1323
|
+
const video = await client.createVideo(projectId, { assetId, title });
|
|
1324
|
+
log(` video: ${video.videoId}`);
|
|
1325
|
+
const videoClient = client.cfg.videoId ? client : client.withVideoId(video.videoId);
|
|
1326
|
+
const { runId } = await videoClient.ingestStart(stem);
|
|
1327
|
+
log(` ingest run: ${runId}`);
|
|
1328
|
+
const state = await pollRun(videoClient, runId, {
|
|
1196
1329
|
intervalMs: opts.pollIntervalMs,
|
|
1197
1330
|
onEvent: (ev) => log(` ${formatRunEvent(ev)}`)
|
|
1198
1331
|
});
|
|
1199
|
-
assertRunCompleted(state, "
|
|
1200
|
-
const pulled = await pullFiles(
|
|
1201
|
-
|
|
1332
|
+
assertRunCompleted(state, "ingest");
|
|
1333
|
+
const pulled = await pullFiles(videoClient, jobDir, { only: ingestArtifactSet(stem), prefixes: ["prompts/"] });
|
|
1334
|
+
const durationS = readProbeDuration(jobDir);
|
|
1335
|
+
const job = {
|
|
1336
|
+
video_id: video.videoId,
|
|
1337
|
+
project_id: video.projectId,
|
|
1338
|
+
api_base: client.cfg.base,
|
|
1339
|
+
stem,
|
|
1340
|
+
duration_s: durationS,
|
|
1341
|
+
source_path: source
|
|
1342
|
+
};
|
|
1343
|
+
const jobJson = writeJobFile(jobDir, job);
|
|
1344
|
+
return {
|
|
1345
|
+
jobDir,
|
|
1346
|
+
jobJson,
|
|
1347
|
+
videoId: video.videoId,
|
|
1348
|
+
projectId: video.projectId,
|
|
1349
|
+
stem,
|
|
1350
|
+
durationS,
|
|
1351
|
+
uploaded: source,
|
|
1352
|
+
uploadedBytes,
|
|
1353
|
+
runId,
|
|
1354
|
+
pulled,
|
|
1355
|
+
webUrl: webVideoUrl(client.cfg.base, video.projectId, video.videoId)
|
|
1356
|
+
};
|
|
1202
1357
|
}
|
|
1203
1358
|
|
|
1204
|
-
// src/
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
minSilence: 0.14,
|
|
1208
|
-
targetGap: 0.09,
|
|
1209
|
-
leadKeep: 0.04,
|
|
1210
|
-
trailKeep: 0.05,
|
|
1211
|
-
minSegment: 0.1
|
|
1212
|
-
};
|
|
1213
|
-
function f3(x) {
|
|
1214
|
-
return pyFixed(x, 3);
|
|
1215
|
-
}
|
|
1216
|
-
function silenceDetectArgs(source, noiseDb, minSilenceS) {
|
|
1217
|
-
return [
|
|
1218
|
-
"ffmpeg",
|
|
1219
|
-
"-hide_banner",
|
|
1220
|
-
"-nostats",
|
|
1221
|
-
"-i",
|
|
1222
|
-
source,
|
|
1223
|
-
"-af",
|
|
1224
|
-
`silencedetect=noise=${noiseDb}dB:d=${f3(minSilenceS)}`,
|
|
1225
|
-
"-vn",
|
|
1226
|
-
"-f",
|
|
1227
|
-
"null",
|
|
1228
|
-
"-"
|
|
1229
|
-
];
|
|
1359
|
+
// src/cloud/transcribe2.ts
|
|
1360
|
+
function transcribeArtifactSet(stem) {
|
|
1361
|
+
return [`transcripts/${stem}.json`, "takes_packed.md", "flags.txt", "word_dump.txt", "coverage.json", "probe.json"];
|
|
1230
1362
|
}
|
|
1231
|
-
function
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1363
|
+
async function transcribeCloud(client, jobDir, opts) {
|
|
1364
|
+
const verbose = opts.verbose !== false;
|
|
1365
|
+
const log = (line) => {
|
|
1366
|
+
if (verbose) process.stderr.write(line + "\n");
|
|
1367
|
+
};
|
|
1368
|
+
const transcriptPath = `transcripts/${opts.stem}.json`;
|
|
1369
|
+
const listing = await client.filesList();
|
|
1370
|
+
const haveTranscript = listing.some((f) => f.path === transcriptPath);
|
|
1371
|
+
let runId = null;
|
|
1372
|
+
if (haveTranscript) {
|
|
1373
|
+
log(` ${transcriptPath} already exists server-side \u2014 pulling artifacts`);
|
|
1374
|
+
} else {
|
|
1375
|
+
const started = await client.ingestStart(opts.stem);
|
|
1376
|
+
runId = started.runId;
|
|
1377
|
+
log(` ingest run: ${runId}`);
|
|
1378
|
+
const state = await pollRun(client, runId, {
|
|
1379
|
+
intervalMs: opts.pollIntervalMs,
|
|
1380
|
+
onEvent: (ev) => log(` ${formatRunEvent(ev)}`)
|
|
1381
|
+
});
|
|
1382
|
+
assertRunCompleted(state, "ingest");
|
|
1249
1383
|
}
|
|
1250
|
-
|
|
1251
|
-
}
|
|
1252
|
-
async function detectSilences(source, noiseDb, minSilenceS, sourceDurationS) {
|
|
1253
|
-
const res = await run(silenceDetectArgs(source, noiseDb, minSilenceS), {
|
|
1254
|
-
capture: true,
|
|
1255
|
-
check: false,
|
|
1256
|
-
echo: false
|
|
1257
|
-
});
|
|
1258
|
-
return parseSilenceDetect(res.stderr, sourceDurationS);
|
|
1384
|
+
const pulled = await pullFiles(client, jobDir, { only: transcribeArtifactSet(opts.stem), prefixes: ["prompts/"] });
|
|
1385
|
+
return { runId, pulled };
|
|
1259
1386
|
}
|
|
1260
1387
|
|
|
1261
1388
|
// src/timeline-v2/schema.ts
|
|
@@ -1728,20 +1855,20 @@ function checkCapabilities(uses, registry = CAPABILITIES) {
|
|
|
1728
1855
|
}
|
|
1729
1856
|
|
|
1730
1857
|
// src/cloud/compile2.ts
|
|
1731
|
-
import { existsSync as
|
|
1732
|
-
import { isAbsolute, join as
|
|
1858
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1859
|
+
import { isAbsolute, join as join6, resolve as resolve2 } from "path";
|
|
1733
1860
|
function timelineJsonPath2(jobDir) {
|
|
1734
|
-
return
|
|
1861
|
+
return join6(jobDir, "timeline.json");
|
|
1735
1862
|
}
|
|
1736
1863
|
function planPath2(jobDir) {
|
|
1737
|
-
return
|
|
1864
|
+
return join6(jobDir, "resolved", "plan.json");
|
|
1738
1865
|
}
|
|
1739
1866
|
function diagnosticsPath2(jobDir) {
|
|
1740
|
-
return
|
|
1867
|
+
return join6(jobDir, "resolved", "diagnostics.json");
|
|
1741
1868
|
}
|
|
1742
1869
|
function loadTimeline2(jobDir) {
|
|
1743
1870
|
const path = timelineJsonPath2(jobDir);
|
|
1744
|
-
if (!
|
|
1871
|
+
if (!existsSync5(path)) {
|
|
1745
1872
|
return {
|
|
1746
1873
|
doc: null,
|
|
1747
1874
|
raw: null,
|
|
@@ -1750,7 +1877,7 @@ function loadTimeline2(jobDir) {
|
|
|
1750
1877
|
}
|
|
1751
1878
|
let raw;
|
|
1752
1879
|
try {
|
|
1753
|
-
raw = JSON.parse(
|
|
1880
|
+
raw = JSON.parse(readFileSync4(path, "utf8"));
|
|
1754
1881
|
} catch (err) {
|
|
1755
1882
|
return {
|
|
1756
1883
|
doc: null,
|
|
@@ -1775,22 +1902,11 @@ function validateLocal(doc) {
|
|
|
1775
1902
|
const warnings = [...sem.warnings, ...cap.warnings];
|
|
1776
1903
|
return { ok: errors.length === 0, uses, errors, warnings };
|
|
1777
1904
|
}
|
|
1778
|
-
async function detectSilencesForDoc(jobDir, doc) {
|
|
1779
|
-
const dopts = { ...DEFAULT_DESILENCE, ...doc.settings.desilence ?? {} };
|
|
1780
|
-
const out = {};
|
|
1781
|
-
for (const [name, src] of Object.entries(doc.sources)) {
|
|
1782
|
-
const srcPath = isAbsolute(src.path) ? src.path : resolve2(jobDir, src.path);
|
|
1783
|
-
if (!existsSync6(srcPath)) continue;
|
|
1784
|
-
const dur = src.durationS ?? await mediaDuration(srcPath);
|
|
1785
|
-
out[name] = await detectSilences(srcPath, dopts.noiseDb, dopts.minSilence, dur);
|
|
1786
|
-
}
|
|
1787
|
-
return out;
|
|
1788
|
-
}
|
|
1789
1905
|
function statAssetsForDoc(jobDir, doc) {
|
|
1790
1906
|
const out = {};
|
|
1791
1907
|
const statRef = (ref) => {
|
|
1792
1908
|
if (ref !== void 0 && !(ref in out)) {
|
|
1793
|
-
out[ref] =
|
|
1909
|
+
out[ref] = existsSync5(isAbsolute(ref) ? ref : resolve2(jobDir, ref));
|
|
1794
1910
|
}
|
|
1795
1911
|
};
|
|
1796
1912
|
for (const ov of doc.overlays) statRef(ov.asset?.ref);
|
|
@@ -1799,60 +1915,37 @@ function statAssetsForDoc(jobDir, doc) {
|
|
|
1799
1915
|
}
|
|
1800
1916
|
async function compileRemote(client, jobDir, loaded) {
|
|
1801
1917
|
if (!loaded.doc) throw new Error("compileRemote requires a parsed timeline");
|
|
1802
|
-
const silences = await detectSilencesForDoc(jobDir, loaded.doc);
|
|
1803
1918
|
const assets = statAssetsForDoc(jobDir, loaded.doc);
|
|
1804
1919
|
const res = await client.compileRemote({
|
|
1805
1920
|
timeline: loaded.raw,
|
|
1806
|
-
...Object.keys(silences).length > 0 ? { silences_by_source: silences } : {},
|
|
1807
1921
|
...Object.keys(assets).length > 0 ? { assets_present: assets } : {}
|
|
1808
1922
|
});
|
|
1809
|
-
const dir =
|
|
1810
|
-
|
|
1923
|
+
const dir = join6(jobDir, "resolved");
|
|
1924
|
+
mkdirSync5(dir, { recursive: true });
|
|
1811
1925
|
const out = { ok: res.ok === true, diagnosticsPath: diagnosticsPath2(jobDir), response: res };
|
|
1812
|
-
|
|
1926
|
+
writeFileSync4(out.diagnosticsPath, JSON.stringify(res.diagnostics ?? { errors: [], warnings: [], dropped: [] }, null, 2) + "\n", "utf8");
|
|
1813
1927
|
if (res.ok === true && res.plan !== void 0 && res.plan !== null) {
|
|
1814
1928
|
out.planPath = planPath2(jobDir);
|
|
1815
|
-
|
|
1929
|
+
writeFileSync4(out.planPath, JSON.stringify(res.plan, null, 2) + "\n", "utf8");
|
|
1816
1930
|
}
|
|
1817
1931
|
if (res.ok === true && res.legacy_edl !== void 0 && res.legacy_edl !== null) {
|
|
1818
|
-
out.edlPath =
|
|
1819
|
-
|
|
1932
|
+
out.edlPath = join6(jobDir, "edl.json");
|
|
1933
|
+
writeFileSync4(out.edlPath, JSON.stringify(res.legacy_edl, null, 2) + "\n", "utf8");
|
|
1820
1934
|
}
|
|
1821
1935
|
return out;
|
|
1822
1936
|
}
|
|
1823
1937
|
|
|
1824
1938
|
// src/cloud/verify2.ts
|
|
1825
|
-
import { existsSync as
|
|
1826
|
-
import { join as
|
|
1939
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
|
|
1940
|
+
import { join as join7 } from "path";
|
|
1827
1941
|
async function verifyCloud(client, jobDir, opts = {}) {
|
|
1828
1942
|
const verbose = opts.verbose !== false;
|
|
1829
1943
|
const log = (line) => {
|
|
1830
1944
|
if (verbose) process.stderr.write(line + "\n");
|
|
1831
1945
|
};
|
|
1832
|
-
const preview = join9(jobDir, "preview.mp4");
|
|
1833
|
-
if (!existsSync7(preview)) {
|
|
1834
|
-
throw new Error(`preview not found (render first, e.g. \`video-editing cloud render --mode preview --wait\`): ${preview}`);
|
|
1835
|
-
}
|
|
1836
|
-
log(" extracting preview audio");
|
|
1837
|
-
const { bytes, durationS } = await extractGuardedWav(preview);
|
|
1838
|
-
const wavPath = "audio/preview.wav";
|
|
1839
|
-
log(` uploading ${wavPath} (${(bytes.length / (1024 * 1024)).toFixed(1)} MB)`);
|
|
1840
|
-
await uploadVfsBytes(client, wavPath, bytes, "audio/wav");
|
|
1841
|
-
let edl;
|
|
1842
|
-
const edlPath = join9(jobDir, "edl.json");
|
|
1843
|
-
if (existsSync7(edlPath)) {
|
|
1844
|
-
try {
|
|
1845
|
-
edl = JSON.parse(readFileSync6(edlPath, "utf8"));
|
|
1846
|
-
} catch {
|
|
1847
|
-
edl = void 0;
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
1946
|
const { runId } = await client.verifyStart({
|
|
1851
|
-
wav_path: wavPath,
|
|
1852
|
-
preview_duration_s: durationS,
|
|
1853
1947
|
...opts.full ? { mode: "full" } : {},
|
|
1854
|
-
...opts.windowS !== void 0 ? { window_s: opts.windowS } : {}
|
|
1855
|
-
...edl !== void 0 ? { edl } : {}
|
|
1948
|
+
...opts.windowS !== void 0 ? { window_s: opts.windowS } : {}
|
|
1856
1949
|
});
|
|
1857
1950
|
log(` verify run: ${runId}`);
|
|
1858
1951
|
const state = await pollRun(client, runId, {
|
|
@@ -1861,12 +1954,12 @@ async function verifyCloud(client, jobDir, opts = {}) {
|
|
|
1861
1954
|
});
|
|
1862
1955
|
assertRunCompleted(state, "verify");
|
|
1863
1956
|
await pullFiles(client, jobDir, { only: ["verify.json"] });
|
|
1864
|
-
const verifyPath =
|
|
1957
|
+
const verifyPath = join7(jobDir, "verify.json");
|
|
1865
1958
|
let verdict = null;
|
|
1866
1959
|
let findings = null;
|
|
1867
|
-
if (
|
|
1960
|
+
if (existsSync6(verifyPath)) {
|
|
1868
1961
|
try {
|
|
1869
|
-
const v = JSON.parse(
|
|
1962
|
+
const v = JSON.parse(readFileSync5(verifyPath, "utf8"));
|
|
1870
1963
|
verdict = typeof v.verdict === "string" ? v.verdict : null;
|
|
1871
1964
|
findings = Array.isArray(v.joins) ? v.joins.length : null;
|
|
1872
1965
|
} catch {
|
|
@@ -1875,302 +1968,128 @@ async function verifyCloud(client, jobDir, opts = {}) {
|
|
|
1875
1968
|
return { runId, verifyPath, verdict, findings };
|
|
1876
1969
|
}
|
|
1877
1970
|
|
|
1878
|
-
// src/cloud/
|
|
1879
|
-
import {
|
|
1880
|
-
import {
|
|
1881
|
-
function
|
|
1882
|
-
return
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
"-c:a",
|
|
1925
|
-
"aac",
|
|
1926
|
-
"-b:a",
|
|
1927
|
-
"160k",
|
|
1928
|
-
"-movflags",
|
|
1929
|
-
"+faststart",
|
|
1930
|
-
dest
|
|
1931
|
-
]);
|
|
1932
|
-
} else {
|
|
1933
|
-
process.stderr.write(" proxy up to date; skipping re-encode\n");
|
|
1934
|
-
}
|
|
1935
|
-
return dest;
|
|
1936
|
-
}
|
|
1937
|
-
if (entry.role === "audio") {
|
|
1938
|
-
const preview = join10(jobDir, "preview.mp4");
|
|
1939
|
-
if (existsSync8(preview)) {
|
|
1940
|
-
if (stale(dest, preview)) {
|
|
1941
|
-
await run([
|
|
1942
|
-
"ffmpeg",
|
|
1943
|
-
"-y",
|
|
1944
|
-
"-hide_banner",
|
|
1945
|
-
"-loglevel",
|
|
1946
|
-
"error",
|
|
1947
|
-
"-stats",
|
|
1948
|
-
"-i",
|
|
1949
|
-
preview,
|
|
1950
|
-
"-vn",
|
|
1951
|
-
"-c:a",
|
|
1952
|
-
"aac",
|
|
1953
|
-
"-b:a",
|
|
1954
|
-
"160k",
|
|
1955
|
-
dest
|
|
1956
|
-
]);
|
|
1957
|
-
} else {
|
|
1958
|
-
process.stderr.write(" cut audio up to date; skipping re-extract\n");
|
|
1959
|
-
}
|
|
1960
|
-
return dest;
|
|
1961
|
-
}
|
|
1962
|
-
const master = join10(jobDir, "vertical_src.mp4");
|
|
1963
|
-
const planPath = join10(jobDir, "resolved", "plan.json");
|
|
1964
|
-
if (!existsSync8(master) || !existsSync8(planPath)) {
|
|
1965
|
-
throw new Error(
|
|
1966
|
-
`cut audio needs either <job>/preview.mp4 or vertical_src.mp4 + resolved/plan.json (compile first)`
|
|
1967
|
-
);
|
|
1971
|
+
// src/cloud/qa.ts
|
|
1972
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
1973
|
+
import { join as join8 } from "path";
|
|
1974
|
+
function qaDir(jobDir) {
|
|
1975
|
+
return jobDir ? join8(jobDir, "qa") : join8(process.cwd(), "qa");
|
|
1976
|
+
}
|
|
1977
|
+
function tag(start, end) {
|
|
1978
|
+
return `${start}_${end}`;
|
|
1979
|
+
}
|
|
1980
|
+
function framesPngPath(dir, start, end, suffix = "") {
|
|
1981
|
+
return join8(dir, `frames_${tag(start, end)}${suffix}.png`);
|
|
1982
|
+
}
|
|
1983
|
+
function waveformPngPath(dir, start, end) {
|
|
1984
|
+
return join8(dir, `waveform_${tag(start, end)}.png`);
|
|
1985
|
+
}
|
|
1986
|
+
function inspectPngPath(dir, start, end, suffix = "") {
|
|
1987
|
+
return join8(dir, `inspect_${tag(start, end)}${suffix}.png`);
|
|
1988
|
+
}
|
|
1989
|
+
function waveformWordsPath(dir, start, end) {
|
|
1990
|
+
return join8(dir, `waveform_${tag(start, end)}.words.json`);
|
|
1991
|
+
}
|
|
1992
|
+
function readPlanSegments(jobDir) {
|
|
1993
|
+
const path = join8(jobDir, "resolved", "plan.json");
|
|
1994
|
+
if (!existsSync7(path)) {
|
|
1995
|
+
throw new Error(`no compiled plan at ${path} \u2014 run \`video-editing timeline compile\` first`);
|
|
1996
|
+
}
|
|
1997
|
+
let raw;
|
|
1998
|
+
try {
|
|
1999
|
+
raw = JSON.parse(readFileSync6(path, "utf8"));
|
|
2000
|
+
} catch (err) {
|
|
2001
|
+
throw new Error(`resolved/plan.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
2002
|
+
}
|
|
2003
|
+
const segments = raw?.segments;
|
|
2004
|
+
if (!Array.isArray(segments) || segments.length === 0) {
|
|
2005
|
+
throw new Error(`resolved/plan.json has no segments[]: ${path}`);
|
|
2006
|
+
}
|
|
2007
|
+
return segments.map((s, i) => {
|
|
2008
|
+
const r = s;
|
|
2009
|
+
const seg = {
|
|
2010
|
+
srcStart: Number(r.srcStart ?? NaN),
|
|
2011
|
+
srcEnd: Number(r.srcEnd ?? NaN),
|
|
2012
|
+
outStart: Number(r.outStart ?? NaN),
|
|
2013
|
+
outEnd: Number(r.outEnd ?? NaN)
|
|
2014
|
+
};
|
|
2015
|
+
if (![seg.srcStart, seg.srcEnd, seg.outStart, seg.outEnd].every(Number.isFinite)) {
|
|
2016
|
+
throw new Error(`plan segment ${i} is missing src/out times: ${JSON.stringify(s)}`);
|
|
1968
2017
|
}
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2018
|
+
return seg;
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
var round3 = (n) => Math.round(n * 1e3) / 1e3;
|
|
2022
|
+
var SUFFIXES = "abcdefghijklmnopqrstuvwxyz";
|
|
2023
|
+
function suffixed(windows) {
|
|
2024
|
+
return windows.map((w, i) => ({
|
|
2025
|
+
start: round3(w.start),
|
|
2026
|
+
end: round3(w.end),
|
|
2027
|
+
suffix: windows.length > 1 ? `-${SUFFIXES[i] ?? String(i)}` : ""
|
|
2028
|
+
}));
|
|
2029
|
+
}
|
|
2030
|
+
function joinSourceWindows(segments, joinIndex, spanS) {
|
|
2031
|
+
if (!Number.isInteger(joinIndex) || joinIndex < 1 || joinIndex > segments.length - 1) {
|
|
2032
|
+
throw new Error(
|
|
2033
|
+
`--join must be a 1-based join index between 1 and ${segments.length - 1} (the plan has ${segments.length} segment(s))`
|
|
1972
2034
|
);
|
|
1973
|
-
if (segs.length === 0) throw new Error("resolved/plan.json has no usable segments for cut audio");
|
|
1974
|
-
const trims = segs.map((x, i) => `[0:a]atrim=start=${x.srcStart}:end=${x.srcEnd},asetpts=PTS-STARTPTS[a${i}]`).join(";");
|
|
1975
|
-
const labels = segs.map((_, i) => `[a${i}]`).join("");
|
|
1976
|
-
const filter = `${trims};${labels}concat=n=${segs.length}:v=0:a=1[out]`;
|
|
1977
|
-
await run([
|
|
1978
|
-
"ffmpeg",
|
|
1979
|
-
"-y",
|
|
1980
|
-
"-hide_banner",
|
|
1981
|
-
"-loglevel",
|
|
1982
|
-
"error",
|
|
1983
|
-
"-stats",
|
|
1984
|
-
"-i",
|
|
1985
|
-
master,
|
|
1986
|
-
"-filter_complex",
|
|
1987
|
-
filter,
|
|
1988
|
-
"-map",
|
|
1989
|
-
"[out]",
|
|
1990
|
-
"-c:a",
|
|
1991
|
-
"aac",
|
|
1992
|
-
"-b:a",
|
|
1993
|
-
"160k",
|
|
1994
|
-
dest
|
|
1995
|
-
]);
|
|
1996
|
-
return dest;
|
|
1997
|
-
}
|
|
1998
|
-
const ref = entry.src.replace(/^media\//, "");
|
|
1999
|
-
const srcPath = resolvePath2(jobDir, ref);
|
|
2000
|
-
if (!existsSync8(srcPath)) {
|
|
2001
|
-
throw new Error(`asset not found: ${ref} (expected at ${srcPath}) \u2014 asset refs are job-dir-relative paths`);
|
|
2002
|
-
}
|
|
2003
|
-
copyFileSync(srcPath, dest);
|
|
2004
|
-
return dest;
|
|
2005
|
-
}
|
|
2006
|
-
async function composeCloud(client, jobDir, opts = {}) {
|
|
2007
|
-
const quality = opts.quality ?? "standard";
|
|
2008
|
-
if (!["draft", "standard", "high"].includes(quality)) throw new Error("--quality must be draft|standard|high");
|
|
2009
|
-
const fps = opts.fps ?? 30;
|
|
2010
|
-
const proxyCrf = opts.proxyCrf ?? 23;
|
|
2011
|
-
const res = await client.getCompose();
|
|
2012
|
-
const compDir = join10(jobDir, "composition");
|
|
2013
|
-
mkdirSync7(join10(compDir, "media"), { recursive: true });
|
|
2014
|
-
const entry = join10(compDir, "index.html");
|
|
2015
|
-
writeFileSync6(entry, res.html, "utf8");
|
|
2016
|
-
for (const m of res.media) {
|
|
2017
|
-
await buildMediaEntry(jobDir, compDir, m, { proxyCrf });
|
|
2018
|
-
}
|
|
2019
|
-
const out = resolvePath2(opts.out ?? join10(jobDir, "preview.mp4"));
|
|
2020
|
-
const renderCmd = ["hyperframes", "render", "--quality", quality, "--fps", String(fps), "--output", out];
|
|
2021
|
-
const result = {
|
|
2022
|
-
compositionDir: compDir,
|
|
2023
|
-
entry,
|
|
2024
|
-
counts: res.counts,
|
|
2025
|
-
media: res.media,
|
|
2026
|
-
renderCmd,
|
|
2027
|
-
rendered: false
|
|
2028
|
-
};
|
|
2029
|
-
if (!opts.render) {
|
|
2030
|
-
if (opts.verbose !== false) {
|
|
2031
|
-
process.stderr.write(`render locally: cd ${shown([compDir])} && ${shown(renderCmd)}
|
|
2032
|
-
`);
|
|
2033
|
-
}
|
|
2034
|
-
return result;
|
|
2035
2035
|
}
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2036
|
+
if (!(spanS > 0)) throw new Error("--span must be > 0 seconds");
|
|
2037
|
+
const a = segments[joinIndex - 1];
|
|
2038
|
+
const b = segments[joinIndex];
|
|
2039
|
+
return suffixed([
|
|
2040
|
+
{ start: Math.max(a.srcStart, a.srcEnd - spanS), end: a.srcEnd },
|
|
2041
|
+
{ start: b.srcStart, end: Math.min(b.srcEnd, b.srcStart + spanS) }
|
|
2042
|
+
]);
|
|
2043
|
+
}
|
|
2044
|
+
function joinSpanWindow(segments, joinIndex, spanS) {
|
|
2045
|
+
if (!Number.isInteger(joinIndex) || joinIndex < 1 || joinIndex > segments.length - 1) {
|
|
2046
|
+
throw new Error(
|
|
2047
|
+
`--join must be a 1-based join index between 1 and ${segments.length - 1} (the plan has ${segments.length} segment(s))`
|
|
2048
|
+
);
|
|
2039
2049
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
return result;
|
|
2050
|
+
if (!(spanS > 0)) throw new Error("--span must be > 0 seconds");
|
|
2051
|
+
const a = segments[joinIndex - 1];
|
|
2052
|
+
const b = segments[joinIndex];
|
|
2053
|
+
return { start: round3(Math.max(0, a.srcEnd - spanS)), end: round3(b.srcStart + spanS), suffix: "" };
|
|
2045
2054
|
}
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
const rows = Math.ceil(frames / cols);
|
|
2055
|
-
const fps = frames / dur;
|
|
2056
|
-
const width = Math.max(64, Math.round(opts.frameWidth ?? 216));
|
|
2057
|
-
return [
|
|
2058
|
-
"ffmpeg",
|
|
2059
|
-
"-y",
|
|
2060
|
-
"-hide_banner",
|
|
2061
|
-
"-loglevel",
|
|
2062
|
-
"error",
|
|
2063
|
-
"-ss",
|
|
2064
|
-
win.start.toFixed(3),
|
|
2065
|
-
"-t",
|
|
2066
|
-
dur.toFixed(3),
|
|
2067
|
-
"-i",
|
|
2068
|
-
win.video,
|
|
2069
|
-
"-frames:v",
|
|
2070
|
-
"1",
|
|
2071
|
-
"-vf",
|
|
2072
|
-
`fps=${fps.toFixed(6)},scale=${width}:-2,tile=${cols}x${rows}`,
|
|
2073
|
-
win.output
|
|
2074
|
-
];
|
|
2075
|
-
}
|
|
2076
|
-
function qaWaveformArgs(win, opts = {}) {
|
|
2077
|
-
const dur = Math.max(0.05, win.end - win.start);
|
|
2078
|
-
const w = Math.max(64, Math.round(opts.width ?? 1200));
|
|
2079
|
-
const h = Math.max(32, Math.round(opts.height ?? 240));
|
|
2080
|
-
return [
|
|
2081
|
-
"ffmpeg",
|
|
2082
|
-
"-y",
|
|
2083
|
-
"-hide_banner",
|
|
2084
|
-
"-loglevel",
|
|
2085
|
-
"error",
|
|
2086
|
-
"-ss",
|
|
2087
|
-
win.start.toFixed(3),
|
|
2088
|
-
"-t",
|
|
2089
|
-
dur.toFixed(3),
|
|
2090
|
-
"-i",
|
|
2091
|
-
win.video,
|
|
2092
|
-
"-filter_complex",
|
|
2093
|
-
`showwavespic=s=${w}x${h}:split_channels=0`,
|
|
2094
|
-
"-frames:v",
|
|
2095
|
-
"1",
|
|
2096
|
-
win.output
|
|
2097
|
-
];
|
|
2098
|
-
}
|
|
2099
|
-
function qaWordsForWindow(transcriptJson, start, end) {
|
|
2100
|
-
const data = JSON.parse(transcriptJson);
|
|
2101
|
-
const out = [];
|
|
2102
|
-
for (const w of data.words ?? []) {
|
|
2103
|
-
if ((w.type ?? "word") !== "word") continue;
|
|
2104
|
-
if (typeof w.start !== "number" || typeof w.end !== "number") continue;
|
|
2105
|
-
if (w.end < start || w.start > end) continue;
|
|
2106
|
-
const text = typeof w.text === "string" ? w.text.trim() : "";
|
|
2107
|
-
if (!text) continue;
|
|
2108
|
-
out.push({ t: w.start, end: w.end, text });
|
|
2055
|
+
function outWindowToSourceWindows(segments, outStart, outEnd) {
|
|
2056
|
+
if (!(outEnd > outStart)) throw new Error("--out-end must be greater than --out-start");
|
|
2057
|
+
const windows = [];
|
|
2058
|
+
for (const seg of segments) {
|
|
2059
|
+
const s = Math.max(outStart, seg.outStart);
|
|
2060
|
+
const e = Math.min(outEnd, seg.outEnd);
|
|
2061
|
+
if (e - s <= 5e-4) continue;
|
|
2062
|
+
windows.push({ start: seg.srcStart + (s - seg.outStart), end: seg.srcStart + (e - seg.outStart) });
|
|
2109
2063
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
const dir = join11(jobDir, "transcripts");
|
|
2114
|
-
if (stem) {
|
|
2115
|
-
const direct = join11(dir, `${stem}.json`);
|
|
2116
|
-
if (existsSync9(direct)) return direct;
|
|
2117
|
-
}
|
|
2118
|
-
if (!existsSync9(dir)) return null;
|
|
2119
|
-
const all = readdirSync2(dir).filter((f) => f.endsWith(".json"));
|
|
2120
|
-
return all.length === 1 ? join11(dir, all[0]) : null;
|
|
2121
|
-
}
|
|
2122
|
-
async function qaFramesLocal(jobDir, args) {
|
|
2123
|
-
const dir = join11(jobDir, "qa");
|
|
2124
|
-
mkdirSync8(dir, { recursive: true });
|
|
2125
|
-
const png = join11(dir, `frames_${args.start}_${args.end}.png`);
|
|
2126
|
-
await run(qaFramesArgs({ video: args.video, start: args.start, end: args.end, output: png }, args), {
|
|
2127
|
-
capture: true,
|
|
2128
|
-
echo: false
|
|
2129
|
-
});
|
|
2130
|
-
return { png };
|
|
2131
|
-
}
|
|
2132
|
-
async function qaWaveformLocal(jobDir, args) {
|
|
2133
|
-
const dir = join11(jobDir, "qa");
|
|
2134
|
-
mkdirSync8(dir, { recursive: true });
|
|
2135
|
-
const png = join11(dir, `waveform_${args.start}_${args.end}.png`);
|
|
2136
|
-
await run(qaWaveformArgs({ video: args.video, start: args.start, end: args.end, output: png }), {
|
|
2137
|
-
capture: true,
|
|
2138
|
-
echo: false
|
|
2139
|
-
});
|
|
2140
|
-
const result = { png };
|
|
2141
|
-
const tpath = findLocalTranscript(jobDir, args.stem);
|
|
2142
|
-
if (tpath) {
|
|
2143
|
-
const words = qaWordsForWindow(readFileSync8(tpath, "utf8"), args.start, args.end);
|
|
2144
|
-
const wordsPath = join11(dir, `waveform_${args.start}_${args.end}.words.json`);
|
|
2145
|
-
writeFileSync7(wordsPath, JSON.stringify(words, null, 2) + "\n", "utf8");
|
|
2146
|
-
result.wordsPath = wordsPath;
|
|
2147
|
-
result.words = words.length;
|
|
2064
|
+
if (windows.length === 0) {
|
|
2065
|
+
const total = segments.length ? Math.max(...segments.map((s) => s.outEnd)) : 0;
|
|
2066
|
+
throw new Error(`output window ${outStart}s\u2013${outEnd}s does not overlap the plan (output duration ${total}s)`);
|
|
2148
2067
|
}
|
|
2149
|
-
return
|
|
2068
|
+
return suffixed(windows);
|
|
2150
2069
|
}
|
|
2151
2070
|
|
|
2152
2071
|
// src/cloud/review2.ts
|
|
2153
2072
|
import { createHash as createHash2 } from "crypto";
|
|
2154
|
-
import { existsSync as
|
|
2155
|
-
import { basename, isAbsolute as isAbsolute2, join as
|
|
2073
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
2074
|
+
import { basename, isAbsolute as isAbsolute2, join as join9 } from "path";
|
|
2156
2075
|
function reviewDir2(jobDir) {
|
|
2157
|
-
return
|
|
2076
|
+
return join9(jobDir, "review");
|
|
2158
2077
|
}
|
|
2159
2078
|
function roundJsonPath2(jobDir) {
|
|
2160
|
-
return
|
|
2079
|
+
return join9(reviewDir2(jobDir), "round.json");
|
|
2161
2080
|
}
|
|
2162
2081
|
function instructionPath2(jobDir) {
|
|
2163
|
-
return
|
|
2082
|
+
return join9(reviewDir2(jobDir), "instruction.md");
|
|
2164
2083
|
}
|
|
2165
2084
|
function assetsCachePath2(jobDir) {
|
|
2166
|
-
return
|
|
2085
|
+
return join9(reviewDir2(jobDir), "assets.json");
|
|
2167
2086
|
}
|
|
2168
2087
|
async function pullReview2(client, jobDir, opts = {}) {
|
|
2169
2088
|
const session = await client.getReview();
|
|
2170
|
-
|
|
2089
|
+
mkdirSync6(reviewDir2(jobDir), { recursive: true });
|
|
2171
2090
|
const round = session.activeRound;
|
|
2172
2091
|
if (round) {
|
|
2173
|
-
|
|
2092
|
+
writeFileSync5(
|
|
2174
2093
|
roundJsonPath2(jobDir),
|
|
2175
2094
|
JSON.stringify(
|
|
2176
2095
|
{
|
|
@@ -2188,20 +2107,20 @@ async function pullReview2(client, jobDir, opts = {}) {
|
|
|
2188
2107
|
) + "\n",
|
|
2189
2108
|
"utf8"
|
|
2190
2109
|
);
|
|
2191
|
-
|
|
2110
|
+
writeFileSync5(instructionPath2(jobDir), round.instruction + "\n", "utf8");
|
|
2192
2111
|
} else {
|
|
2193
|
-
|
|
2112
|
+
writeFileSync5(instructionPath2(jobDir), "No active review round for this video.\n", "utf8");
|
|
2194
2113
|
}
|
|
2195
2114
|
let wroteTimeline = false;
|
|
2196
2115
|
const tlPath = timelineJsonPath2(jobDir);
|
|
2197
|
-
if (session.timeline !== null && session.timeline !== void 0 && !
|
|
2198
|
-
|
|
2116
|
+
if (session.timeline !== null && session.timeline !== void 0 && !existsSync8(tlPath)) {
|
|
2117
|
+
writeFileSync5(tlPath, JSON.stringify(session.timeline, null, 2) + "\n", "utf8");
|
|
2199
2118
|
wroteTimeline = true;
|
|
2200
2119
|
}
|
|
2201
2120
|
let downloadedSource = null;
|
|
2202
2121
|
if (opts.withSource && session.sourceUrl) {
|
|
2203
|
-
const dest =
|
|
2204
|
-
if (!
|
|
2122
|
+
const dest = join9(jobDir, "source.mp4");
|
|
2123
|
+
if (!existsSync8(dest)) {
|
|
2205
2124
|
const dl = await client.download(session.sourceUrl, dest);
|
|
2206
2125
|
downloadedSource = dl.path;
|
|
2207
2126
|
}
|
|
@@ -2211,9 +2130,9 @@ async function pullReview2(client, jobDir, opts = {}) {
|
|
|
2211
2130
|
function resolveRunId2(jobDir, flagOverride) {
|
|
2212
2131
|
if (typeof flagOverride === "string" && flagOverride.trim() !== "") return flagOverride.trim();
|
|
2213
2132
|
const path = roundJsonPath2(jobDir);
|
|
2214
|
-
if (
|
|
2133
|
+
if (existsSync8(path)) {
|
|
2215
2134
|
try {
|
|
2216
|
-
const runId = JSON.parse(
|
|
2135
|
+
const runId = JSON.parse(readFileSync7(path, "utf8")).run_id;
|
|
2217
2136
|
if (typeof runId === "string" && runId !== "") return runId;
|
|
2218
2137
|
} catch {
|
|
2219
2138
|
}
|
|
@@ -2273,8 +2192,8 @@ async function syncAssets2(client, jobDir, plan) {
|
|
|
2273
2192
|
if (refs.length === 0) return {};
|
|
2274
2193
|
const paths = /* @__PURE__ */ new Map();
|
|
2275
2194
|
for (const ref of refs) {
|
|
2276
|
-
const abs = isAbsolute2(ref) ? ref :
|
|
2277
|
-
if (!
|
|
2195
|
+
const abs = isAbsolute2(ref) ? ref : join9(jobDir, ref);
|
|
2196
|
+
if (!existsSync8(abs)) {
|
|
2278
2197
|
throw new Error(
|
|
2279
2198
|
`asset not found: ${ref} (expected at ${abs}) \u2014 asset refs are job-dir-relative paths; place the file before pushing`
|
|
2280
2199
|
);
|
|
@@ -2282,9 +2201,9 @@ async function syncAssets2(client, jobDir, plan) {
|
|
|
2282
2201
|
paths.set(ref, abs);
|
|
2283
2202
|
}
|
|
2284
2203
|
let cache = {};
|
|
2285
|
-
if (
|
|
2204
|
+
if (existsSync8(assetsCachePath2(jobDir))) {
|
|
2286
2205
|
try {
|
|
2287
|
-
const raw = JSON.parse(
|
|
2206
|
+
const raw = JSON.parse(readFileSync7(assetsCachePath2(jobDir), "utf8"));
|
|
2288
2207
|
for (const [ref, v] of Object.entries(raw ?? {})) {
|
|
2289
2208
|
const e = v;
|
|
2290
2209
|
if (e && typeof e.sha256 === "string" && typeof e.asset_id === "string") {
|
|
@@ -2298,7 +2217,7 @@ async function syncAssets2(client, jobDir, plan) {
|
|
|
2298
2217
|
const manifest = {};
|
|
2299
2218
|
let dirty = false;
|
|
2300
2219
|
for (const ref of refs) {
|
|
2301
|
-
const bytes =
|
|
2220
|
+
const bytes = readFileSync7(paths.get(ref));
|
|
2302
2221
|
const sha256 = "sha256:" + createHash2("sha256").update(bytes).digest("hex");
|
|
2303
2222
|
const hit = cache[ref];
|
|
2304
2223
|
if (hit !== void 0 && hit.sha256 === sha256) {
|
|
@@ -2313,8 +2232,8 @@ async function syncAssets2(client, jobDir, plan) {
|
|
|
2313
2232
|
dirty = true;
|
|
2314
2233
|
}
|
|
2315
2234
|
if (dirty) {
|
|
2316
|
-
|
|
2317
|
-
|
|
2235
|
+
mkdirSync6(reviewDir2(jobDir), { recursive: true });
|
|
2236
|
+
writeFileSync5(assetsCachePath2(jobDir), JSON.stringify(cache, null, 2) + "\n", "utf8");
|
|
2318
2237
|
}
|
|
2319
2238
|
return manifest;
|
|
2320
2239
|
}
|
|
@@ -2347,10 +2266,10 @@ async function pushTimeline2(client, jobDir, runId) {
|
|
|
2347
2266
|
return { ok: true, plan: compiled.plan, timelineHash: compiled.timelineHash, assets };
|
|
2348
2267
|
}
|
|
2349
2268
|
function readOutcomesFile2(path) {
|
|
2350
|
-
if (!
|
|
2269
|
+
if (!existsSync8(path)) throw new Error(`outcomes file not found: ${path}`);
|
|
2351
2270
|
let raw;
|
|
2352
2271
|
try {
|
|
2353
|
-
raw = JSON.parse(
|
|
2272
|
+
raw = JSON.parse(readFileSync7(path, "utf8"));
|
|
2354
2273
|
} catch (err) {
|
|
2355
2274
|
throw new Error(`outcomes file is not valid JSON: ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2356
2275
|
}
|
|
@@ -2387,16 +2306,16 @@ async function pushComplete2(client, jobDir, runId, opts) {
|
|
|
2387
2306
|
}
|
|
2388
2307
|
|
|
2389
2308
|
// src/public/config.ts
|
|
2390
|
-
import { existsSync as
|
|
2309
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
|
|
2391
2310
|
import { homedir as homedir2 } from "os";
|
|
2392
|
-
import { join as
|
|
2311
|
+
import { join as join10 } from "path";
|
|
2393
2312
|
function publicConfigDir() {
|
|
2394
2313
|
const xdg = process.env.XDG_CONFIG_HOME;
|
|
2395
|
-
const base = xdg && xdg.trim() ? xdg :
|
|
2396
|
-
return
|
|
2314
|
+
const base = xdg && xdg.trim() ? xdg : join10(homedir2(), ".config");
|
|
2315
|
+
return join10(base, "video-editing");
|
|
2397
2316
|
}
|
|
2398
2317
|
function publicConfigEnvPath() {
|
|
2399
|
-
return
|
|
2318
|
+
return join10(publicConfigDir(), ".env");
|
|
2400
2319
|
}
|
|
2401
2320
|
function parseEnvLines(text) {
|
|
2402
2321
|
return text.split(/\r?\n/).map((raw) => {
|
|
@@ -2412,10 +2331,10 @@ function parseEnvLines(text) {
|
|
|
2412
2331
|
var MANAGED_KEYS = ["CLIPREADY_API_KEY", "CLIPREADY_API_BASE"];
|
|
2413
2332
|
function loadPublicConfigEnv(env = process.env) {
|
|
2414
2333
|
const path = publicConfigEnvPath();
|
|
2415
|
-
if (!
|
|
2334
|
+
if (!existsSync9(path)) return;
|
|
2416
2335
|
let text;
|
|
2417
2336
|
try {
|
|
2418
|
-
text =
|
|
2337
|
+
text = readFileSync8(path, "utf8");
|
|
2419
2338
|
} catch {
|
|
2420
2339
|
return;
|
|
2421
2340
|
}
|
|
@@ -2425,22 +2344,31 @@ function loadPublicConfigEnv(env = process.env) {
|
|
|
2425
2344
|
if (!env[line.key] || !String(env[line.key]).trim()) env[line.key] = line.value;
|
|
2426
2345
|
}
|
|
2427
2346
|
}
|
|
2347
|
+
function removePublicConfigKey(key) {
|
|
2348
|
+
const path = publicConfigEnvPath();
|
|
2349
|
+
if (!existsSync9(path)) return path;
|
|
2350
|
+
const lines = readFileSync8(path, "utf8").split(/\r?\n/).filter((l) => l.trim() !== "").filter((l) => {
|
|
2351
|
+
const eq = l.indexOf("=");
|
|
2352
|
+
return eq < 0 || l.slice(0, eq).trim() !== key;
|
|
2353
|
+
});
|
|
2354
|
+
writeFileSync6(path, lines.length ? lines.join("\n") + "\n" : "", { mode: 384 });
|
|
2355
|
+
return path;
|
|
2356
|
+
}
|
|
2428
2357
|
function savePublicConfigKey(key, value) {
|
|
2429
|
-
|
|
2358
|
+
mkdirSync7(publicConfigDir(), { recursive: true });
|
|
2430
2359
|
const path = publicConfigEnvPath();
|
|
2431
|
-
const existing =
|
|
2360
|
+
const existing = existsSync9(path) ? readFileSync8(path, "utf8") : "";
|
|
2432
2361
|
const lines = existing.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
2433
2362
|
const kept = lines.filter((l) => {
|
|
2434
2363
|
const eq = l.indexOf("=");
|
|
2435
2364
|
return eq < 0 || l.slice(0, eq).trim() !== key;
|
|
2436
2365
|
});
|
|
2437
2366
|
kept.push(`${key}=${value}`);
|
|
2438
|
-
|
|
2367
|
+
writeFileSync6(path, kept.join("\n") + "\n", { mode: 384 });
|
|
2439
2368
|
return path;
|
|
2440
2369
|
}
|
|
2441
2370
|
|
|
2442
2371
|
export {
|
|
2443
|
-
requireFfmpeg,
|
|
2444
2372
|
DEFAULT_TIMEOUT_MS,
|
|
2445
2373
|
DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
2446
2374
|
resolveApiBase,
|
|
@@ -2455,9 +2383,13 @@ export {
|
|
|
2455
2383
|
meUrl,
|
|
2456
2384
|
projectsUrl,
|
|
2457
2385
|
projectVideosUrl,
|
|
2386
|
+
webVideoUrl,
|
|
2458
2387
|
runUrl,
|
|
2388
|
+
multipartUrl,
|
|
2389
|
+
multipartRelayUrl,
|
|
2459
2390
|
filesUrl,
|
|
2460
2391
|
transcribeUrl,
|
|
2392
|
+
ingestUrl,
|
|
2461
2393
|
compileUrl,
|
|
2462
2394
|
resolveUrl,
|
|
2463
2395
|
verifyUrl,
|
|
@@ -2483,7 +2415,13 @@ export {
|
|
|
2483
2415
|
writeJobFile,
|
|
2484
2416
|
readJobFile,
|
|
2485
2417
|
videoIdFromJob,
|
|
2486
|
-
|
|
2418
|
+
MULTIPART_THRESHOLD_BYTES,
|
|
2419
|
+
RELAY_DIRECT_ATTEMPTS,
|
|
2420
|
+
RELAY_SUBCHUNK_BYTES,
|
|
2421
|
+
shouldMultipart,
|
|
2422
|
+
bytesSource,
|
|
2423
|
+
openFileSource,
|
|
2424
|
+
uploadMultipart,
|
|
2487
2425
|
INLINE_MAX_BYTES,
|
|
2488
2426
|
sha256Hex,
|
|
2489
2427
|
sameSha,
|
|
@@ -2492,18 +2430,18 @@ export {
|
|
|
2492
2430
|
vfsContentType,
|
|
2493
2431
|
pullFiles,
|
|
2494
2432
|
pushFiles,
|
|
2433
|
+
uploadVfsPayload,
|
|
2495
2434
|
uploadVfsBytes,
|
|
2496
2435
|
MAX_RUN_POLL_MS,
|
|
2497
2436
|
formatRunEvent,
|
|
2498
2437
|
pollRun,
|
|
2499
2438
|
assertRunCompleted,
|
|
2439
|
+
sanitizeStem,
|
|
2440
|
+
ingestArtifactSet,
|
|
2441
|
+
readProbeDuration,
|
|
2442
|
+
initCloudJob,
|
|
2500
2443
|
transcribeArtifactSet,
|
|
2501
|
-
extractGuardedWav,
|
|
2502
2444
|
transcribeCloud,
|
|
2503
|
-
DEFAULT_DESILENCE,
|
|
2504
|
-
silenceDetectArgs,
|
|
2505
|
-
parseSilenceDetect,
|
|
2506
|
-
detectSilences,
|
|
2507
2445
|
CAPTION_STYLE_DEFAULTS,
|
|
2508
2446
|
timelineV2Schema,
|
|
2509
2447
|
emptyDiagnostics,
|
|
@@ -2517,18 +2455,18 @@ export {
|
|
|
2517
2455
|
diagnosticsPath2,
|
|
2518
2456
|
loadTimeline2,
|
|
2519
2457
|
validateLocal,
|
|
2520
|
-
detectSilencesForDoc,
|
|
2521
2458
|
statAssetsForDoc,
|
|
2522
2459
|
compileRemote,
|
|
2523
2460
|
verifyCloud,
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2461
|
+
qaDir,
|
|
2462
|
+
framesPngPath,
|
|
2463
|
+
waveformPngPath,
|
|
2464
|
+
inspectPngPath,
|
|
2465
|
+
waveformWordsPath,
|
|
2466
|
+
readPlanSegments,
|
|
2467
|
+
joinSourceWindows,
|
|
2468
|
+
joinSpanWindow,
|
|
2469
|
+
outWindowToSourceWindows,
|
|
2532
2470
|
reviewDir2,
|
|
2533
2471
|
roundJsonPath2,
|
|
2534
2472
|
instructionPath2,
|
|
@@ -2545,5 +2483,6 @@ export {
|
|
|
2545
2483
|
publicConfigDir,
|
|
2546
2484
|
publicConfigEnvPath,
|
|
2547
2485
|
loadPublicConfigEnv,
|
|
2486
|
+
removePublicConfigKey,
|
|
2548
2487
|
savePublicConfigKey
|
|
2549
2488
|
};
|