video-editing 2.0.0 → 3.1.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-2OM7WV4W.js} +755 -801
- package/dist/cli.js +332 -131
- package/dist/index.d.ts +185 -138
- package/dist/index.js +43 -29
- package/package.json +2 -2
- package/skills/video-editing/SKILL.md +108 -74
- package/skills/video-editing/references/audio-joins.md +94 -0
- package/skills/video-editing/references/cli-reference.md +122 -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
|
}
|
|
@@ -208,6 +104,9 @@ function resolveUrl(base, videoId) {
|
|
|
208
104
|
function verifyUrl(base, videoId) {
|
|
209
105
|
return videoUrl(base, videoId, "verify");
|
|
210
106
|
}
|
|
107
|
+
function smoothJoinsUrl(base, videoId) {
|
|
108
|
+
return videoUrl(base, videoId, "smooth-joins");
|
|
109
|
+
}
|
|
211
110
|
function composeUrl(base, videoId) {
|
|
212
111
|
return videoUrl(base, videoId, "compose");
|
|
213
112
|
}
|
|
@@ -279,6 +178,18 @@ function parseQaWaveform(data) {
|
|
|
279
178
|
}) : [];
|
|
280
179
|
return { url: o.url, words };
|
|
281
180
|
}
|
|
181
|
+
function parseQaInspect(data) {
|
|
182
|
+
const o = asRecord(data, "qa-inspect");
|
|
183
|
+
if (typeof o.url !== "string") throw new Error(`qa inspect response missing url: ${JSON.stringify(data)}`);
|
|
184
|
+
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) })) : [];
|
|
185
|
+
return {
|
|
186
|
+
url: o.url,
|
|
187
|
+
tStart: Number(o.tStart ?? 0),
|
|
188
|
+
tEnd: Number(o.tEnd ?? 0),
|
|
189
|
+
cuts,
|
|
190
|
+
cached: o.cached === true
|
|
191
|
+
};
|
|
192
|
+
}
|
|
282
193
|
function parseFileList(data) {
|
|
283
194
|
const o = asRecord(data, "file-list");
|
|
284
195
|
const arr = o.files;
|
|
@@ -411,7 +322,10 @@ function extractErrorMessage(data) {
|
|
|
411
322
|
}
|
|
412
323
|
return void 0;
|
|
413
324
|
}
|
|
414
|
-
|
|
325
|
+
function backoffDelayMs(attempt) {
|
|
326
|
+
return 500 * 2 ** (attempt - 1);
|
|
327
|
+
}
|
|
328
|
+
var CloudClient = class _CloudClient {
|
|
415
329
|
constructor(cfg, opts = {}) {
|
|
416
330
|
this.cfg = cfg;
|
|
417
331
|
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
@@ -425,6 +339,61 @@ var CloudClient = class {
|
|
|
425
339
|
if (!this.cfg.videoId) throw new Error("missing video id \u2014 pass --video-id or set VIDEO_ID");
|
|
426
340
|
return this.cfg.videoId;
|
|
427
341
|
}
|
|
342
|
+
/** Same client (same fetch/timeout) bound to a video id — for flows that
|
|
343
|
+
* create the video mid-run (init). */
|
|
344
|
+
withVideoId(videoId) {
|
|
345
|
+
return new _CloudClient({ ...this.cfg, videoId }, { fetchImpl: this.fetchImpl, timeoutMs: this.timeoutMs });
|
|
346
|
+
}
|
|
347
|
+
/** The injected fetch — for helpers outside the class (multipart part PUTs)
|
|
348
|
+
* that need raw Response access (e.g. the ETag header). */
|
|
349
|
+
get fetch() {
|
|
350
|
+
return this.fetchImpl;
|
|
351
|
+
}
|
|
352
|
+
/** POST a JSON body to an absolute API URL with auth — public wrapper over
|
|
353
|
+
* the private transport for helpers living outside the class (multipart). */
|
|
354
|
+
async postJson(url, body) {
|
|
355
|
+
return this.request("POST", url, body);
|
|
356
|
+
}
|
|
357
|
+
/** POST raw binary bytes to an authenticated API URL (multipart relay
|
|
358
|
+
* sub-chunks). Content-Type application/octet-stream; JSON response. */
|
|
359
|
+
async postBinary(url, bytes, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
360
|
+
const ac = new AbortController();
|
|
361
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
362
|
+
try {
|
|
363
|
+
const res = await this.fetchImpl(url, {
|
|
364
|
+
method: "POST",
|
|
365
|
+
headers: {
|
|
366
|
+
Authorization: `Bearer ${this.cfg.credential}`,
|
|
367
|
+
Accept: "application/json",
|
|
368
|
+
"Content-Type": "application/octet-stream"
|
|
369
|
+
},
|
|
370
|
+
body: bytes,
|
|
371
|
+
signal: ac.signal
|
|
372
|
+
});
|
|
373
|
+
const text = await res.text();
|
|
374
|
+
let data = void 0;
|
|
375
|
+
if (text) {
|
|
376
|
+
try {
|
|
377
|
+
data = JSON.parse(text);
|
|
378
|
+
} catch {
|
|
379
|
+
data = text;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
if (!res.ok) {
|
|
383
|
+
const detail = extractErrorMessage(data) ?? `${res.status} ${res.statusText}`;
|
|
384
|
+
throw new CloudHttpError(res.status, `POST ${url} failed: ${detail}`, data);
|
|
385
|
+
}
|
|
386
|
+
return data;
|
|
387
|
+
} catch (err) {
|
|
388
|
+
if (err instanceof CloudHttpError) throw err;
|
|
389
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
390
|
+
throw new Error(`request timed out after ${timeoutMs}ms: POST ${url}`);
|
|
391
|
+
}
|
|
392
|
+
throw err;
|
|
393
|
+
} finally {
|
|
394
|
+
clearTimeout(timer);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
428
397
|
async request(method, url, body) {
|
|
429
398
|
const ac = new AbortController();
|
|
430
399
|
const timer = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
@@ -491,6 +460,12 @@ var CloudClient = class {
|
|
|
491
460
|
});
|
|
492
461
|
return parseQaWaveform(data);
|
|
493
462
|
}
|
|
463
|
+
async qaInspect(args) {
|
|
464
|
+
const body = { kind: "inspect", start: args.start, end: args.end };
|
|
465
|
+
if (args.nFrames !== void 0) body.n_frames = args.nFrames;
|
|
466
|
+
const data = await this.request("POST", qaUrl(this.cfg.base, this.requireVideoId()), body);
|
|
467
|
+
return parseQaInspect(data);
|
|
468
|
+
}
|
|
494
469
|
// ---- artifacts ----------------------------------------------------------
|
|
495
470
|
async listArtifacts() {
|
|
496
471
|
const data = await this.request("GET", artifactsUrl(this.cfg.base, this.requireVideoId()));
|
|
@@ -512,28 +487,45 @@ var CloudClient = class {
|
|
|
512
487
|
return parseAssetCreate(data);
|
|
513
488
|
}
|
|
514
489
|
/** 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
|
-
|
|
490
|
+
* signed URL carries its own credentials and may be a different host.
|
|
491
|
+
* Mid-stream network failures (connection resets, TLS record errors) and
|
|
492
|
+
* 5xx responses are retried with exponential backoff — the whole body is
|
|
493
|
+
* an in-memory buffer, so every attempt is a clean full replay. */
|
|
494
|
+
async upload(url, bytes, contentType, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS, maxAttempts = 5) {
|
|
495
|
+
let lastErr;
|
|
496
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
497
|
+
const ac = new AbortController();
|
|
498
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
499
|
+
try {
|
|
500
|
+
const res = await this.fetchImpl(url, {
|
|
501
|
+
method: "PUT",
|
|
502
|
+
headers: { "Content-Type": contentType },
|
|
503
|
+
body: bytes,
|
|
504
|
+
signal: ac.signal
|
|
505
|
+
});
|
|
506
|
+
if (res.ok) return;
|
|
507
|
+
const failure = new Error(`asset upload failed: HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
508
|
+
if (res.status < 500 || attempt === maxAttempts) throw failure;
|
|
509
|
+
lastErr = failure;
|
|
510
|
+
} catch (err) {
|
|
511
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
512
|
+
throw new Error(`asset upload timed out after ${timeoutMs}ms: ${url}`);
|
|
513
|
+
}
|
|
514
|
+
const cause = err.cause;
|
|
515
|
+
const detail = cause?.code ?? cause?.message;
|
|
516
|
+
const network = err instanceof TypeError;
|
|
517
|
+
if (!network || attempt === maxAttempts) {
|
|
518
|
+
throw err instanceof TypeError ? new Error(
|
|
519
|
+
`upload network failure after ${attempt} attempt${attempt === 1 ? "" : "s"}${detail ? ` (${detail})` : ""} PUTting ${bytes.length} bytes to ${new URL(url).host}`
|
|
520
|
+
) : err;
|
|
521
|
+
}
|
|
522
|
+
lastErr = err;
|
|
523
|
+
} finally {
|
|
524
|
+
clearTimeout(timer);
|
|
532
525
|
}
|
|
533
|
-
|
|
534
|
-
} finally {
|
|
535
|
-
clearTimeout(timer);
|
|
526
|
+
await new Promise((r) => setTimeout(r, backoffDelayMs(attempt)));
|
|
536
527
|
}
|
|
528
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
537
529
|
}
|
|
538
530
|
// ---- review session -----------------------------------------------------
|
|
539
531
|
async getReview() {
|
|
@@ -641,8 +633,22 @@ var CloudClient = class {
|
|
|
641
633
|
}
|
|
642
634
|
return { runId: o.run_id };
|
|
643
635
|
}
|
|
644
|
-
/** POST /videos/{id}/
|
|
645
|
-
|
|
636
|
+
/** POST /videos/{id}/ingest {stem?} → 202 {run_id}. Server-side probe + audio
|
|
637
|
+
* extraction + transcription + screening over the uploaded source. The stem
|
|
638
|
+
* names every derived artifact (transcripts/<stem>.json, audio/<stem>.wav) —
|
|
639
|
+
* omit it and the server defaults to "source". */
|
|
640
|
+
async ingestStart(stem) {
|
|
641
|
+
const body = stem !== void 0 && stem !== "" ? { stem } : {};
|
|
642
|
+
const data = await this.request("POST", ingestUrl(this.cfg.base, this.requireVideoId()), body);
|
|
643
|
+
const o = asRecord(data, "ingest");
|
|
644
|
+
if (typeof o.run_id !== "string" || o.run_id === "") {
|
|
645
|
+
throw new Error(`ingest response missing run_id: ${JSON.stringify(data)}`);
|
|
646
|
+
}
|
|
647
|
+
return { runId: o.run_id };
|
|
648
|
+
}
|
|
649
|
+
/** POST /videos/{id}/verify {mode?, window_s?} → 202 {run_id}. The server
|
|
650
|
+
* sources the preview audio itself — nothing is uploaded. */
|
|
651
|
+
async verifyStart(body = {}) {
|
|
646
652
|
const data = await this.request("POST", verifyUrl(this.cfg.base, this.requireVideoId()), body);
|
|
647
653
|
const o = asRecord(data, "verify");
|
|
648
654
|
if (typeof o.run_id !== "string" || o.run_id === "") {
|
|
@@ -650,6 +656,18 @@ var CloudClient = class {
|
|
|
650
656
|
}
|
|
651
657
|
return { runId: o.run_id };
|
|
652
658
|
}
|
|
659
|
+
async smoothJoinsStart(body = {}) {
|
|
660
|
+
const data = await this.request(
|
|
661
|
+
"POST",
|
|
662
|
+
smoothJoinsUrl(this.cfg.base, this.requireVideoId()),
|
|
663
|
+
body
|
|
664
|
+
);
|
|
665
|
+
const o = asRecord(data, "smooth-joins");
|
|
666
|
+
if (typeof o.run_id !== "string" || o.run_id === "") {
|
|
667
|
+
throw new Error(`smooth-joins response missing run_id: ${JSON.stringify(data)}`);
|
|
668
|
+
}
|
|
669
|
+
return { runId: o.run_id };
|
|
670
|
+
}
|
|
653
671
|
/** GET /runs/{id} — run status + events. */
|
|
654
672
|
async getRun(runId) {
|
|
655
673
|
const data = await this.request("GET", runUrl(this.cfg.base, runId));
|
|
@@ -686,14 +704,14 @@ async function downloadTo(url, dest, opts = {}) {
|
|
|
686
704
|
try {
|
|
687
705
|
const res = await f(url, { signal: ac.signal });
|
|
688
706
|
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
689
|
-
|
|
707
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
690
708
|
const body = res.body;
|
|
691
709
|
if (!body) {
|
|
692
710
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
693
711
|
writeFileSync(dest, buf);
|
|
694
712
|
return { path: dest, bytes: buf.length };
|
|
695
713
|
}
|
|
696
|
-
await pipeline(Readable.fromWeb(body),
|
|
714
|
+
await pipeline(Readable.fromWeb(body), createWriteStream(dest));
|
|
697
715
|
return { path: dest, bytes: statSync(dest).size };
|
|
698
716
|
} catch (err) {
|
|
699
717
|
if (err instanceof Error && err.name === "AbortError") {
|
|
@@ -733,10 +751,10 @@ async function pollRender(client, renderId, opts = {}) {
|
|
|
733
751
|
// src/cloud/skill.ts
|
|
734
752
|
import { cpSync, existsSync, readdirSync, rmSync, statSync as statSync2 } from "fs";
|
|
735
753
|
import { homedir } from "os";
|
|
736
|
-
import { dirname as
|
|
754
|
+
import { dirname as dirname2, join as join2, relative, resolve } from "path";
|
|
737
755
|
import { fileURLToPath } from "url";
|
|
738
756
|
function bundledSkillDir() {
|
|
739
|
-
const here =
|
|
757
|
+
const here = dirname2(fileURLToPath(import.meta.url));
|
|
740
758
|
const candidates = [
|
|
741
759
|
join2(here, "skills", "video-editing"),
|
|
742
760
|
join2(here, "..", "skills", "video-editing"),
|
|
@@ -770,13 +788,13 @@ function installSkill(opts = {}) {
|
|
|
770
788
|
}
|
|
771
789
|
|
|
772
790
|
// src/cloud/jobfile.ts
|
|
773
|
-
import { existsSync as existsSync2, mkdirSync as
|
|
791
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
774
792
|
import { join as join3 } from "path";
|
|
775
793
|
function jobJsonPath(jobDir) {
|
|
776
794
|
return join3(jobDir, "job.json");
|
|
777
795
|
}
|
|
778
796
|
function writeJobFile(jobDir, job) {
|
|
779
|
-
|
|
797
|
+
mkdirSync2(jobDir, { recursive: true });
|
|
780
798
|
const path = jobJsonPath(jobDir);
|
|
781
799
|
writeFileSync2(path, JSON.stringify(job, null, 2) + "\n", "utf8");
|
|
782
800
|
return path;
|
|
@@ -792,7 +810,8 @@ function readJobFile(jobDir) {
|
|
|
792
810
|
project_id: typeof raw.project_id === "string" ? raw.project_id : "",
|
|
793
811
|
api_base: typeof raw.api_base === "string" ? raw.api_base : "",
|
|
794
812
|
stem: typeof raw.stem === "string" ? raw.stem : "source",
|
|
795
|
-
duration_s: typeof raw.duration_s === "number" ? raw.duration_s : 0
|
|
813
|
+
duration_s: typeof raw.duration_s === "number" ? raw.duration_s : 0,
|
|
814
|
+
...typeof raw.source_path === "string" && raw.source_path !== "" ? { source_path: raw.source_path } : {}
|
|
796
815
|
};
|
|
797
816
|
} catch {
|
|
798
817
|
return null;
|
|
@@ -805,184 +824,276 @@ function videoIdFromJob(jobDir, resolved) {
|
|
|
805
824
|
throw new Error(`missing video id \u2014 pass --video-id, set VIDEO_ID, or run \`init\` first (no ${jobJsonPath(jobDir)})`);
|
|
806
825
|
}
|
|
807
826
|
|
|
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;
|
|
827
|
+
// src/cloud/multipart.ts
|
|
828
|
+
import { open } from "fs/promises";
|
|
829
|
+
async function runPool(tasks, concurrency) {
|
|
830
|
+
let next = 0;
|
|
831
|
+
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
|
|
832
|
+
while (next < tasks.length) {
|
|
833
|
+
const task = tasks[next++];
|
|
834
|
+
if (task) await task();
|
|
823
835
|
}
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
836
|
+
});
|
|
837
|
+
await Promise.all(workers);
|
|
838
|
+
}
|
|
839
|
+
var MULTIPART_THRESHOLD_BYTES = 8 * 1024 * 1024;
|
|
840
|
+
var PART_CONCURRENCY = 3;
|
|
841
|
+
var PART_MAX_ATTEMPTS = 8;
|
|
842
|
+
var RELAY_DIRECT_ATTEMPTS = 3;
|
|
843
|
+
var RELAY_SUBCHUNK_BYTES = 3670016;
|
|
844
|
+
function jitteredBackoffMs(attempt) {
|
|
845
|
+
const base = Math.min(500 * 2 ** (attempt - 1), 8e3);
|
|
846
|
+
return Math.round(base / 2 + Math.random() * (base / 2));
|
|
847
|
+
}
|
|
848
|
+
function shouldMultipart(sizeOrBytes) {
|
|
849
|
+
const size = typeof sizeOrBytes === "number" ? sizeOrBytes : sizeOrBytes.length;
|
|
850
|
+
return size > MULTIPART_THRESHOLD_BYTES;
|
|
851
|
+
}
|
|
852
|
+
function bytesSource(bytes) {
|
|
853
|
+
return {
|
|
854
|
+
size: bytes.length,
|
|
855
|
+
readSlice: async (offset, len) => Buffer.from(bytes.subarray(offset, Math.min(offset + len, bytes.length)))
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
async function openFileSource(path) {
|
|
859
|
+
const fh = await open(path, "r");
|
|
860
|
+
const size = (await fh.stat()).size;
|
|
861
|
+
return {
|
|
862
|
+
size,
|
|
863
|
+
async readSlice(offset, len) {
|
|
864
|
+
const want = Math.min(len, Math.max(0, size - offset));
|
|
865
|
+
const buf = Buffer.allocUnsafe(want);
|
|
866
|
+
let done = 0;
|
|
867
|
+
while (done < want) {
|
|
868
|
+
const { bytesRead } = await fh.read(buf, done, want - done, offset + done);
|
|
869
|
+
if (bytesRead <= 0) throw new Error(`short read at byte ${offset + done} of ${path}`);
|
|
870
|
+
done += bytesRead;
|
|
871
|
+
}
|
|
872
|
+
return buf;
|
|
873
|
+
},
|
|
874
|
+
close: () => fh.close()
|
|
875
|
+
};
|
|
831
876
|
}
|
|
832
|
-
function
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
877
|
+
function asRecord2(data, ctx) {
|
|
878
|
+
if (data === null || typeof data !== "object") throw new Error(`unexpected ${ctx} response: ${JSON.stringify(data)}`);
|
|
879
|
+
return data;
|
|
880
|
+
}
|
|
881
|
+
function parsePartUrls(data, ctx) {
|
|
882
|
+
const o = asRecord2(data, ctx);
|
|
883
|
+
if (!Array.isArray(o.parts)) throw new Error(`${ctx} response missing parts[]: ${JSON.stringify(data)}`);
|
|
884
|
+
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 !== "");
|
|
885
|
+
}
|
|
886
|
+
function parseMultipartCreate(data) {
|
|
887
|
+
const o = asRecord2(data, "multipart-create");
|
|
888
|
+
const uploadId = o.upload_id;
|
|
889
|
+
if (typeof uploadId !== "string" || uploadId === "") {
|
|
890
|
+
throw new Error(`multipart create response missing upload_id: ${JSON.stringify(data)}`);
|
|
838
891
|
}
|
|
839
|
-
|
|
892
|
+
const partSize = Number(o.part_size ?? 0);
|
|
893
|
+
if (!(partSize > 0)) throw new Error(`multipart create response missing part_size: ${JSON.stringify(data)}`);
|
|
894
|
+
return {
|
|
895
|
+
uploadId,
|
|
896
|
+
assetId: typeof o.asset_id === "string" && o.asset_id !== "" ? o.asset_id : void 0,
|
|
897
|
+
partSize,
|
|
898
|
+
parts: parsePartUrls(data, "multipart-create")
|
|
899
|
+
};
|
|
840
900
|
}
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
901
|
+
function isMultipartUnavailable(err) {
|
|
902
|
+
if (!(err instanceof CloudHttpError)) return false;
|
|
903
|
+
if (err.status === 404 || err.status === 405) return true;
|
|
904
|
+
if (err.status !== 503) return false;
|
|
905
|
+
const body = err.body;
|
|
906
|
+
return typeof body === "object" && body !== null && typeof body.error === "object" && body.error !== null ? body.error.code === "multipart_unavailable" : false;
|
|
907
|
+
}
|
|
908
|
+
function stripEtagQuotes(etag) {
|
|
909
|
+
return etag.trim().replace(/^"+|"+$/g, "");
|
|
910
|
+
}
|
|
911
|
+
function fmtMb(n) {
|
|
912
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
913
|
+
}
|
|
914
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
915
|
+
async function putPart(client, url, chunk, timeoutMs) {
|
|
916
|
+
const ac = new AbortController();
|
|
917
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
918
|
+
try {
|
|
919
|
+
return await client.fetch(url, { method: "PUT", body: chunk, signal: ac.signal });
|
|
920
|
+
} finally {
|
|
921
|
+
clearTimeout(timer);
|
|
860
922
|
}
|
|
861
|
-
let s = (rounded / m).toFixed(prec);
|
|
862
|
-
if (neg && Number(s) !== 0) s = "-" + s;
|
|
863
|
-
return s;
|
|
864
923
|
}
|
|
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;
|
|
924
|
+
async function refreshPartUrl(client, uploadId, n) {
|
|
925
|
+
const data = await client.postJson(multipartUrl(client.cfg.base, "parts"), { upload_id: uploadId, ns: [n] });
|
|
926
|
+
const url = parsePartUrls(data, "multipart-parts").find((p) => p.n === n)?.url;
|
|
927
|
+
if (!url) throw new Error(`multipart parts refresh did not return a URL for part ${n}: ${JSON.stringify(data)}`);
|
|
928
|
+
return url;
|
|
924
929
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
930
|
+
async function relayOnePart(client, uploadId, n, chunk) {
|
|
931
|
+
const of = Math.max(1, Math.ceil(chunk.length / RELAY_SUBCHUNK_BYTES));
|
|
932
|
+
for (let i = 1; i <= of; i++) {
|
|
933
|
+
const sub = chunk.subarray((i - 1) * RELAY_SUBCHUNK_BYTES, Math.min(i * RELAY_SUBCHUNK_BYTES, chunk.length));
|
|
934
|
+
const data = await client.postBinary(multipartRelayUrl(client.cfg.base, uploadId, n, i, of), sub);
|
|
935
|
+
const o = data;
|
|
936
|
+
if (!o || o.ok !== true) {
|
|
937
|
+
throw new Error(`relay sub-chunk ${i}/${of} of part ${n} did not return {ok:true}: ${JSON.stringify(data)}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const done = asRecord2(
|
|
941
|
+
await client.postJson(multipartUrl(client.cfg.base, "relay-complete"), { upload_id: uploadId, n }),
|
|
942
|
+
"relay-complete"
|
|
943
|
+
);
|
|
944
|
+
if (typeof done.etag !== "string" || done.etag === "") {
|
|
945
|
+
throw new Error(`relay-complete for part ${n} returned no etag: ${JSON.stringify(done)}`);
|
|
946
|
+
}
|
|
947
|
+
return stripEtagQuotes(done.etag);
|
|
948
|
+
}
|
|
949
|
+
function isRelayUnavailable(err) {
|
|
950
|
+
return err instanceof CloudHttpError && (err.status === 404 || err.status === 405);
|
|
951
|
+
}
|
|
952
|
+
function transportDetail(err) {
|
|
953
|
+
const cause = err.cause;
|
|
954
|
+
return cause?.code ?? cause?.message;
|
|
955
|
+
}
|
|
956
|
+
async function uploadOnePart(client, uploadId, n, chunk, initialUrl, timeoutMs, backoffMs, diag) {
|
|
957
|
+
let url = initialUrl;
|
|
958
|
+
let lastErr;
|
|
959
|
+
let transportFailures = 0;
|
|
960
|
+
let relayUnavailable = false;
|
|
961
|
+
for (let attempt = 1; attempt <= PART_MAX_ATTEMPTS; attempt++) {
|
|
962
|
+
const useRelay = !relayUnavailable && transportFailures >= RELAY_DIRECT_ATTEMPTS;
|
|
963
|
+
const mode = useRelay ? "relay" : "direct";
|
|
964
|
+
const t0 = Date.now();
|
|
965
|
+
const report = (error) => {
|
|
966
|
+
if (!error && mode === "direct") return;
|
|
967
|
+
diag?.(
|
|
968
|
+
`part ${n} attempt ${attempt} mode ${mode} bytes ${chunk.length} elapsed ${Date.now() - t0}ms` + (error ? ` error ${error}` : " ok")
|
|
969
|
+
);
|
|
970
|
+
};
|
|
971
|
+
if (useRelay) {
|
|
972
|
+
try {
|
|
973
|
+
const etag = await relayOnePart(client, uploadId, n, chunk);
|
|
974
|
+
report();
|
|
975
|
+
return etag;
|
|
976
|
+
} catch (err) {
|
|
977
|
+
if (isRelayUnavailable(err)) {
|
|
978
|
+
relayUnavailable = true;
|
|
979
|
+
report("relay_unavailable");
|
|
980
|
+
} else {
|
|
981
|
+
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
982
|
+
report(err instanceof CloudHttpError ? `http_${err.status}` : lastErr.message.slice(0, 80));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
} else {
|
|
986
|
+
try {
|
|
987
|
+
if (!url) url = await refreshPartUrl(client, uploadId, n);
|
|
988
|
+
const res = await putPart(client, url, chunk, timeoutMs);
|
|
989
|
+
if (res.ok) {
|
|
990
|
+
const etag = res.headers.get("etag");
|
|
991
|
+
if (!etag) throw new Error(`part ${n} uploaded but the response had no ETag header \u2014 cannot complete the multipart upload`);
|
|
992
|
+
report();
|
|
993
|
+
return stripEtagQuotes(etag);
|
|
994
|
+
}
|
|
995
|
+
lastErr = new Error(`part ${n}: HTTP ${res.status} ${res.statusText}`);
|
|
996
|
+
report(`http_${res.status}`);
|
|
997
|
+
if (res.status >= 400 && res.status < 500) url = void 0;
|
|
998
|
+
} catch (err) {
|
|
999
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
1000
|
+
lastErr = new Error(`part ${n}: timed out after ${timeoutMs}ms`);
|
|
1001
|
+
transportFailures += 1;
|
|
1002
|
+
report("timeout");
|
|
1003
|
+
} else if (err instanceof TypeError) {
|
|
1004
|
+
const detail = transportDetail(err);
|
|
1005
|
+
lastErr = new Error(`part ${n}: network failure${detail ? ` (${detail})` : ""}`);
|
|
1006
|
+
transportFailures += 1;
|
|
1007
|
+
report(detail ?? "fetch_failed");
|
|
1008
|
+
} else {
|
|
1009
|
+
throw err;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (attempt < PART_MAX_ATTEMPTS) await sleep2(backoffMs(attempt));
|
|
1014
|
+
}
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`multipart upload failed at part ${n} (${chunk.length} bytes) after ${PART_MAX_ATTEMPTS} attempts: ${lastErr?.message ?? "unknown error"}`
|
|
1017
|
+
);
|
|
931
1018
|
}
|
|
932
|
-
async function
|
|
933
|
-
const source =
|
|
934
|
-
if (!
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
if (opts.verbose !== false) process.stderr.write(line + "\n");
|
|
1019
|
+
async function uploadMultipart(client, opts, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
1020
|
+
const source = opts.source ?? (opts.bytes !== void 0 ? bytesSource(opts.bytes) : void 0);
|
|
1021
|
+
if (!source) throw new Error("uploadMultipart: pass exactly one of bytes or source");
|
|
1022
|
+
const createBody = {
|
|
1023
|
+
kind: opts.kind,
|
|
1024
|
+
size: source.size,
|
|
1025
|
+
content_type: opts.contentType
|
|
940
1026
|
};
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
|
968
|
-
};
|
|
969
|
-
const jobJson = writeJobFile(jobDir, job);
|
|
970
|
-
return {
|
|
971
|
-
jobDir,
|
|
972
|
-
jobJson,
|
|
973
|
-
videoId: video.videoId,
|
|
974
|
-
projectId: video.projectId,
|
|
975
|
-
stem,
|
|
976
|
-
durationS,
|
|
977
|
-
uploaded: uploadPath,
|
|
978
|
-
uploadedBytes: bytes.length
|
|
1027
|
+
if (opts.filename !== void 0) createBody.filename = opts.filename;
|
|
1028
|
+
if (opts.videoId !== void 0) createBody.video_id = opts.videoId;
|
|
1029
|
+
if (opts.path !== void 0) createBody.path = opts.path;
|
|
1030
|
+
let session;
|
|
1031
|
+
try {
|
|
1032
|
+
session = parseMultipartCreate(await client.postJson(multipartUrl(client.cfg.base), createBody));
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
if (isMultipartUnavailable(err)) return "unavailable";
|
|
1035
|
+
throw err;
|
|
1036
|
+
}
|
|
1037
|
+
const abort = async () => {
|
|
1038
|
+
try {
|
|
1039
|
+
await client.postJson(multipartUrl(client.cfg.base, "abort"), { upload_id: session.uploadId });
|
|
1040
|
+
} catch {
|
|
1041
|
+
}
|
|
979
1042
|
};
|
|
1043
|
+
if (opts.kind === "asset" && !session.assetId) {
|
|
1044
|
+
await abort();
|
|
1045
|
+
throw new Error("multipart create (kind asset) returned no asset_id \u2014 cannot create the video afterwards");
|
|
1046
|
+
}
|
|
1047
|
+
const total = Math.max(1, Math.ceil(source.size / session.partSize));
|
|
1048
|
+
const urls = new Map(session.parts.map((p) => [p.n, p.url]));
|
|
1049
|
+
const marks = new Set([1, 2, 3, 4].map((k) => Math.ceil(total * k / 4)));
|
|
1050
|
+
const etags = new Array(total);
|
|
1051
|
+
let doneParts = 0;
|
|
1052
|
+
let doneBytes = 0;
|
|
1053
|
+
try {
|
|
1054
|
+
await runPool(
|
|
1055
|
+
Array.from({ length: total }, (_, i) => async () => {
|
|
1056
|
+
const n = i + 1;
|
|
1057
|
+
const offset = i * session.partSize;
|
|
1058
|
+
const chunk = await source.readSlice(offset, session.partSize);
|
|
1059
|
+
etags[i] = await uploadOnePart(
|
|
1060
|
+
client,
|
|
1061
|
+
session.uploadId,
|
|
1062
|
+
n,
|
|
1063
|
+
chunk,
|
|
1064
|
+
urls.get(n),
|
|
1065
|
+
timeoutMs,
|
|
1066
|
+
opts.backoffMs ?? jitteredBackoffMs,
|
|
1067
|
+
opts.log
|
|
1068
|
+
);
|
|
1069
|
+
doneParts += 1;
|
|
1070
|
+
doneBytes += chunk.length;
|
|
1071
|
+
if (opts.log && marks.has(doneParts)) {
|
|
1072
|
+
opts.log(`upload: ${doneParts}/${total} parts (${fmtMb(doneBytes)} / ${fmtMb(source.size)})`);
|
|
1073
|
+
}
|
|
1074
|
+
}),
|
|
1075
|
+
PART_CONCURRENCY
|
|
1076
|
+
);
|
|
1077
|
+
const completeBody = {
|
|
1078
|
+
upload_id: session.uploadId,
|
|
1079
|
+
parts: etags.map((etag, i) => ({ n: i + 1, etag }))
|
|
1080
|
+
};
|
|
1081
|
+
if (opts.kind === "jobfile") {
|
|
1082
|
+
if (opts.sha256 !== void 0) completeBody.sha256 = opts.sha256;
|
|
1083
|
+
completeBody.bytes = source.size;
|
|
1084
|
+
}
|
|
1085
|
+
await client.postJson(multipartUrl(client.cfg.base, "complete"), completeBody);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
await abort();
|
|
1088
|
+
throw err;
|
|
1089
|
+
}
|
|
1090
|
+
return opts.kind === "asset" ? { assetId: session.assetId } : {};
|
|
980
1091
|
}
|
|
981
1092
|
|
|
982
1093
|
// src/cloud/files.ts
|
|
983
1094
|
import { createHash } from "crypto";
|
|
984
|
-
import { existsSync as
|
|
985
|
-
import { dirname as
|
|
1095
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1096
|
+
import { dirname as dirname3, join as join4, sep } from "path";
|
|
986
1097
|
var INLINE_MAX_BYTES = 5 * 1024 * 1024;
|
|
987
1098
|
function sha256Hex(bytes) {
|
|
988
1099
|
return createHash("sha256").update(bytes).digest("hex");
|
|
@@ -995,10 +1106,10 @@ function sameSha(a, b) {
|
|
|
995
1106
|
function vfsLocalPath(jobDir, path) {
|
|
996
1107
|
const parts = path.split("/").filter((p) => p !== "" && p !== "." && p !== "..");
|
|
997
1108
|
if (parts.length === 0) throw new Error(`bad VFS path: ${JSON.stringify(path)}`);
|
|
998
|
-
return
|
|
1109
|
+
return join4(jobDir, ...parts);
|
|
999
1110
|
}
|
|
1000
1111
|
function toVfsPath(jobDir, localPath) {
|
|
1001
|
-
const abs = localPath.startsWith(sep) ? localPath :
|
|
1112
|
+
const abs = localPath.startsWith(sep) ? localPath : join4(jobDir, localPath);
|
|
1002
1113
|
const rel = abs.startsWith(jobDir + sep) ? abs.slice(jobDir.length + 1) : localPath;
|
|
1003
1114
|
return rel.split(sep).join("/");
|
|
1004
1115
|
}
|
|
@@ -1036,18 +1147,18 @@ async function pullFiles(client, jobDir, opts = {}) {
|
|
|
1036
1147
|
const entry = byPath.get(path);
|
|
1037
1148
|
if (!entry) continue;
|
|
1038
1149
|
const dest = vfsLocalPath(jobDir, path);
|
|
1039
|
-
if (
|
|
1150
|
+
if (existsSync3(dest) && sameSha(sha256Hex(readFileSync2(dest)), entry.sha256)) {
|
|
1040
1151
|
const f2 = { path, localPath: dest, bytes: statSync3(dest).size, skipped: true };
|
|
1041
1152
|
pulled.push(f2);
|
|
1042
1153
|
opts.onFile?.(f2);
|
|
1043
1154
|
continue;
|
|
1044
1155
|
}
|
|
1045
1156
|
const got = await client.filesGet(path);
|
|
1046
|
-
|
|
1157
|
+
mkdirSync3(dirname3(dest), { recursive: true });
|
|
1047
1158
|
let bytes;
|
|
1048
1159
|
if (got.content !== void 0) {
|
|
1049
1160
|
const buf = Buffer.from(got.content, "utf8");
|
|
1050
|
-
|
|
1161
|
+
writeFileSync3(dest, buf);
|
|
1051
1162
|
bytes = buf.length;
|
|
1052
1163
|
if (got.sha256 && !sameSha(sha256Hex(buf), got.sha256)) {
|
|
1053
1164
|
throw new Error(`pulled ${path} but sha256 mismatch vs server (${got.sha256}) \u2014 refusing to trust the copy`);
|
|
@@ -1055,7 +1166,7 @@ async function pullFiles(client, jobDir, opts = {}) {
|
|
|
1055
1166
|
} else {
|
|
1056
1167
|
const dl = await client.download(got.downloadUrl, dest);
|
|
1057
1168
|
bytes = dl.bytes;
|
|
1058
|
-
if (got.sha256 && !sameSha(sha256Hex(
|
|
1169
|
+
if (got.sha256 && !sameSha(sha256Hex(readFileSync2(dest)), got.sha256)) {
|
|
1059
1170
|
throw new Error(`pulled ${path} but sha256 mismatch vs server (${got.sha256}) \u2014 refusing to trust the copy`);
|
|
1060
1171
|
}
|
|
1061
1172
|
}
|
|
@@ -1073,8 +1184,8 @@ async function pushFiles(client, jobDir, paths, opts = {}) {
|
|
|
1073
1184
|
for (const rel of paths) {
|
|
1074
1185
|
const vfsPath = toVfsPath(jobDir, rel);
|
|
1075
1186
|
const local = vfsLocalPath(jobDir, vfsPath);
|
|
1076
|
-
if (!
|
|
1077
|
-
const bytes =
|
|
1187
|
+
if (!existsSync3(local)) throw new Error(`files push: local file not found: ${local}`);
|
|
1188
|
+
const bytes = readFileSync2(local);
|
|
1078
1189
|
const sha = sha256Hex(bytes);
|
|
1079
1190
|
const existing = byPath.get(vfsPath);
|
|
1080
1191
|
if (existing && sameSha(sha, existing.sha256)) {
|
|
@@ -1094,9 +1205,7 @@ async function pushFiles(client, jobDir, paths, opts = {}) {
|
|
|
1094
1205
|
out.push(f);
|
|
1095
1206
|
opts.onFile?.(f);
|
|
1096
1207
|
} else {
|
|
1097
|
-
|
|
1098
|
-
await client.upload(presign.uploadUrl, bytes, contentType);
|
|
1099
|
-
await client.filesCommit(vfsPath);
|
|
1208
|
+
await uploadVfsPayload(client, vfsPath, bytes, contentType, { sha256: sha, log: opts.log });
|
|
1100
1209
|
const f = { path: vfsPath, bytes: bytes.length, skipped: false, mode: "presign" };
|
|
1101
1210
|
out.push(f);
|
|
1102
1211
|
opts.onFile?.(f);
|
|
@@ -1104,20 +1213,32 @@ async function pushFiles(client, jobDir, paths, opts = {}) {
|
|
|
1104
1213
|
}
|
|
1105
1214
|
return out;
|
|
1106
1215
|
}
|
|
1107
|
-
async function
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1216
|
+
async function uploadVfsPayload(client, vfsPath, bytes, contentType, opts = {}) {
|
|
1217
|
+
const sha = opts.sha256 ?? sha256Hex(bytes);
|
|
1218
|
+
if (shouldMultipart(bytes)) {
|
|
1219
|
+
const res = await uploadMultipart(client, {
|
|
1220
|
+
kind: "jobfile",
|
|
1221
|
+
bytes,
|
|
1222
|
+
contentType,
|
|
1223
|
+
videoId: client.cfg.videoId,
|
|
1224
|
+
path: vfsPath,
|
|
1225
|
+
sha256: sha,
|
|
1226
|
+
log: opts.log
|
|
1227
|
+
});
|
|
1228
|
+
if (res !== "unavailable") return;
|
|
1229
|
+
opts.log?.(" chunked upload unavailable on this server; using single-request upload");
|
|
1230
|
+
}
|
|
1231
|
+
const presign = await client.filesPresign({ path: vfsPath, contentType, bytes: bytes.length, sha256: sha });
|
|
1114
1232
|
await client.upload(presign.uploadUrl, bytes, contentType);
|
|
1115
1233
|
await client.filesCommit(vfsPath);
|
|
1116
1234
|
}
|
|
1235
|
+
async function uploadVfsBytes(client, vfsPath, bytes, contentType, log) {
|
|
1236
|
+
await uploadVfsPayload(client, vfsPath, bytes, contentType, { log });
|
|
1237
|
+
}
|
|
1117
1238
|
|
|
1118
1239
|
// src/cloud/runs.ts
|
|
1119
1240
|
var MAX_RUN_POLL_MS = 30 * 60 * 1e3;
|
|
1120
|
-
var
|
|
1241
|
+
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1121
1242
|
function formatRunEvent(ev) {
|
|
1122
1243
|
const p = ev.payload;
|
|
1123
1244
|
const message = p !== null && typeof p === "object" && typeof p.message === "string" ? p.message : "";
|
|
@@ -1126,7 +1247,7 @@ function formatRunEvent(ev) {
|
|
|
1126
1247
|
async function pollRun(client, runId, opts = {}) {
|
|
1127
1248
|
const intervalMs = opts.intervalMs ?? 5e3;
|
|
1128
1249
|
const maxMs = opts.maxMs ?? MAX_RUN_POLL_MS;
|
|
1129
|
-
const nap = opts.sleepImpl ??
|
|
1250
|
+
const nap = opts.sleepImpl ?? sleep3;
|
|
1130
1251
|
const now = opts.now ?? Date.now;
|
|
1131
1252
|
const start = now();
|
|
1132
1253
|
let seen = 0;
|
|
@@ -1147,115 +1268,136 @@ function assertRunCompleted(state, what) {
|
|
|
1147
1268
|
throw new Error(`${what} run ${state.id || ""} ${state.status}: ${detail}`);
|
|
1148
1269
|
}
|
|
1149
1270
|
|
|
1150
|
-
// src/cloud/
|
|
1151
|
-
import {
|
|
1152
|
-
import {
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
return
|
|
1271
|
+
// src/cloud/init.ts
|
|
1272
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3 } from "fs";
|
|
1273
|
+
import { join as join5, parse as parsePath, resolve as resolvePath } from "path";
|
|
1274
|
+
function sanitizeStem(name) {
|
|
1275
|
+
const safe = name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1276
|
+
return safe === "" ? "source" : safe;
|
|
1156
1277
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1278
|
+
function ingestArtifactSet(stem) {
|
|
1279
|
+
return [`transcripts/${stem}.json`, "takes_packed.md", "flags.txt", "word_dump.txt", "coverage.json", "probe.json"];
|
|
1280
|
+
}
|
|
1281
|
+
function readProbeDuration(jobDir) {
|
|
1282
|
+
const path = join5(jobDir, "probe.json");
|
|
1283
|
+
if (!existsSync4(path)) throw new Error(`ingest completed but probe.json was not pulled: ${path}`);
|
|
1284
|
+
let raw;
|
|
1159
1285
|
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 });
|
|
1286
|
+
raw = JSON.parse(readFileSync3(path, "utf8"));
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
throw new Error(`probe.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
1175
1289
|
}
|
|
1290
|
+
const durationS = Number(raw?.duration_s ?? NaN);
|
|
1291
|
+
if (!(durationS > 0)) throw new Error(`probe.json has no usable duration_s: ${JSON.stringify(raw)}`);
|
|
1292
|
+
return durationS;
|
|
1176
1293
|
}
|
|
1177
|
-
|
|
1178
|
-
const
|
|
1294
|
+
function contentTypeFor(path) {
|
|
1295
|
+
const ext = parsePath(path).ext.toLowerCase();
|
|
1296
|
+
const map = { ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo" };
|
|
1297
|
+
return map[ext] ?? "video/mp4";
|
|
1298
|
+
}
|
|
1299
|
+
async function initCloudJob(client, opts) {
|
|
1300
|
+
const source = resolvePath(opts.source);
|
|
1301
|
+
if (!existsSync4(source)) throw new Error(`source not found: ${source}`);
|
|
1302
|
+
const rawName = parsePath(source).name;
|
|
1303
|
+
const stem = sanitizeStem(rawName);
|
|
1304
|
+
const jobDir = resolvePath(opts.jobDir ?? join5(process.cwd(), `job_${stem}`));
|
|
1305
|
+
mkdirSync4(jobDir, { recursive: true });
|
|
1179
1306
|
const log = (line) => {
|
|
1180
|
-
if (verbose) process.stderr.write(line + "\n");
|
|
1307
|
+
if (opts.verbose !== false) process.stderr.write(line + "\n");
|
|
1181
1308
|
};
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
const
|
|
1185
|
-
log(`
|
|
1186
|
-
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
});
|
|
1194
|
-
|
|
1195
|
-
|
|
1309
|
+
await client.me();
|
|
1310
|
+
const title = opts.title ?? rawName;
|
|
1311
|
+
const { projectId } = await client.createProject(title);
|
|
1312
|
+
log(` project: ${projectId}`);
|
|
1313
|
+
const contentType = contentTypeFor(source);
|
|
1314
|
+
const filename = parsePath(source).base;
|
|
1315
|
+
let assetId;
|
|
1316
|
+
let uploadedBytes = 0;
|
|
1317
|
+
const fileSrc = await openFileSource(source);
|
|
1318
|
+
try {
|
|
1319
|
+
uploadedBytes = fileSrc.size;
|
|
1320
|
+
log(` uploading ${filename} (${(fileSrc.size / (1024 * 1024)).toFixed(1)} MB)`);
|
|
1321
|
+
if (shouldMultipart(fileSrc.size)) {
|
|
1322
|
+
const mp = await uploadMultipart(client, { kind: "asset", source: fileSrc, contentType, filename, log });
|
|
1323
|
+
if (mp === "unavailable") {
|
|
1324
|
+
log(" chunked upload unavailable on this server; using single-request upload");
|
|
1325
|
+
} else {
|
|
1326
|
+
assetId = mp.assetId;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
if (!assetId) {
|
|
1330
|
+
const bytes = await fileSrc.readSlice(0, fileSrc.size);
|
|
1331
|
+
const asset = await client.createSourceAsset({ contentType, filename });
|
|
1332
|
+
await client.upload(asset.uploadUrl, bytes, contentType);
|
|
1333
|
+
assetId = asset.assetId;
|
|
1334
|
+
}
|
|
1335
|
+
} finally {
|
|
1336
|
+
await fileSrc.close();
|
|
1337
|
+
}
|
|
1338
|
+
const video = await client.createVideo(projectId, { assetId, title });
|
|
1339
|
+
log(` video: ${video.videoId}`);
|
|
1340
|
+
const videoClient = client.cfg.videoId ? client : client.withVideoId(video.videoId);
|
|
1341
|
+
const { runId } = await videoClient.ingestStart(stem);
|
|
1342
|
+
log(` ingest run: ${runId}`);
|
|
1343
|
+
const state = await pollRun(videoClient, runId, {
|
|
1196
1344
|
intervalMs: opts.pollIntervalMs,
|
|
1197
1345
|
onEvent: (ev) => log(` ${formatRunEvent(ev)}`)
|
|
1198
1346
|
});
|
|
1199
|
-
assertRunCompleted(state, "
|
|
1200
|
-
const pulled = await pullFiles(
|
|
1201
|
-
|
|
1347
|
+
assertRunCompleted(state, "ingest");
|
|
1348
|
+
const pulled = await pullFiles(videoClient, jobDir, { only: ingestArtifactSet(stem), prefixes: ["prompts/"] });
|
|
1349
|
+
const durationS = readProbeDuration(jobDir);
|
|
1350
|
+
const job = {
|
|
1351
|
+
video_id: video.videoId,
|
|
1352
|
+
project_id: video.projectId,
|
|
1353
|
+
api_base: client.cfg.base,
|
|
1354
|
+
stem,
|
|
1355
|
+
duration_s: durationS,
|
|
1356
|
+
source_path: source
|
|
1357
|
+
};
|
|
1358
|
+
const jobJson = writeJobFile(jobDir, job);
|
|
1359
|
+
return {
|
|
1360
|
+
jobDir,
|
|
1361
|
+
jobJson,
|
|
1362
|
+
videoId: video.videoId,
|
|
1363
|
+
projectId: video.projectId,
|
|
1364
|
+
stem,
|
|
1365
|
+
durationS,
|
|
1366
|
+
uploaded: source,
|
|
1367
|
+
uploadedBytes,
|
|
1368
|
+
runId,
|
|
1369
|
+
pulled,
|
|
1370
|
+
webUrl: webVideoUrl(client.cfg.base, video.projectId, video.videoId)
|
|
1371
|
+
};
|
|
1202
1372
|
}
|
|
1203
1373
|
|
|
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
|
-
];
|
|
1374
|
+
// src/cloud/transcribe2.ts
|
|
1375
|
+
function transcribeArtifactSet(stem) {
|
|
1376
|
+
return [`transcripts/${stem}.json`, "takes_packed.md", "flags.txt", "word_dump.txt", "coverage.json", "probe.json"];
|
|
1230
1377
|
}
|
|
1231
|
-
function
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1378
|
+
async function transcribeCloud(client, jobDir, opts) {
|
|
1379
|
+
const verbose = opts.verbose !== false;
|
|
1380
|
+
const log = (line) => {
|
|
1381
|
+
if (verbose) process.stderr.write(line + "\n");
|
|
1382
|
+
};
|
|
1383
|
+
const transcriptPath = `transcripts/${opts.stem}.json`;
|
|
1384
|
+
const listing = await client.filesList();
|
|
1385
|
+
const haveTranscript = listing.some((f) => f.path === transcriptPath);
|
|
1386
|
+
let runId = null;
|
|
1387
|
+
if (haveTranscript) {
|
|
1388
|
+
log(` ${transcriptPath} already exists server-side \u2014 pulling artifacts`);
|
|
1389
|
+
} else {
|
|
1390
|
+
const started = await client.ingestStart(opts.stem);
|
|
1391
|
+
runId = started.runId;
|
|
1392
|
+
log(` ingest run: ${runId}`);
|
|
1393
|
+
const state = await pollRun(client, runId, {
|
|
1394
|
+
intervalMs: opts.pollIntervalMs,
|
|
1395
|
+
onEvent: (ev) => log(` ${formatRunEvent(ev)}`)
|
|
1396
|
+
});
|
|
1397
|
+
assertRunCompleted(state, "ingest");
|
|
1249
1398
|
}
|
|
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);
|
|
1399
|
+
const pulled = await pullFiles(client, jobDir, { only: transcribeArtifactSet(opts.stem), prefixes: ["prompts/"] });
|
|
1400
|
+
return { runId, pulled };
|
|
1259
1401
|
}
|
|
1260
1402
|
|
|
1261
1403
|
// src/timeline-v2/schema.ts
|
|
@@ -1728,20 +1870,20 @@ function checkCapabilities(uses, registry = CAPABILITIES) {
|
|
|
1728
1870
|
}
|
|
1729
1871
|
|
|
1730
1872
|
// src/cloud/compile2.ts
|
|
1731
|
-
import { existsSync as
|
|
1732
|
-
import { isAbsolute, join as
|
|
1873
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
1874
|
+
import { isAbsolute, join as join6, resolve as resolve2 } from "path";
|
|
1733
1875
|
function timelineJsonPath2(jobDir) {
|
|
1734
|
-
return
|
|
1876
|
+
return join6(jobDir, "timeline.json");
|
|
1735
1877
|
}
|
|
1736
1878
|
function planPath2(jobDir) {
|
|
1737
|
-
return
|
|
1879
|
+
return join6(jobDir, "resolved", "plan.json");
|
|
1738
1880
|
}
|
|
1739
1881
|
function diagnosticsPath2(jobDir) {
|
|
1740
|
-
return
|
|
1882
|
+
return join6(jobDir, "resolved", "diagnostics.json");
|
|
1741
1883
|
}
|
|
1742
1884
|
function loadTimeline2(jobDir) {
|
|
1743
1885
|
const path = timelineJsonPath2(jobDir);
|
|
1744
|
-
if (!
|
|
1886
|
+
if (!existsSync5(path)) {
|
|
1745
1887
|
return {
|
|
1746
1888
|
doc: null,
|
|
1747
1889
|
raw: null,
|
|
@@ -1750,7 +1892,7 @@ function loadTimeline2(jobDir) {
|
|
|
1750
1892
|
}
|
|
1751
1893
|
let raw;
|
|
1752
1894
|
try {
|
|
1753
|
-
raw = JSON.parse(
|
|
1895
|
+
raw = JSON.parse(readFileSync4(path, "utf8"));
|
|
1754
1896
|
} catch (err) {
|
|
1755
1897
|
return {
|
|
1756
1898
|
doc: null,
|
|
@@ -1775,22 +1917,11 @@ function validateLocal(doc) {
|
|
|
1775
1917
|
const warnings = [...sem.warnings, ...cap.warnings];
|
|
1776
1918
|
return { ok: errors.length === 0, uses, errors, warnings };
|
|
1777
1919
|
}
|
|
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
1920
|
function statAssetsForDoc(jobDir, doc) {
|
|
1790
1921
|
const out = {};
|
|
1791
1922
|
const statRef = (ref) => {
|
|
1792
1923
|
if (ref !== void 0 && !(ref in out)) {
|
|
1793
|
-
out[ref] =
|
|
1924
|
+
out[ref] = existsSync5(isAbsolute(ref) ? ref : resolve2(jobDir, ref));
|
|
1794
1925
|
}
|
|
1795
1926
|
};
|
|
1796
1927
|
for (const ov of doc.overlays) statRef(ov.asset?.ref);
|
|
@@ -1799,60 +1930,37 @@ function statAssetsForDoc(jobDir, doc) {
|
|
|
1799
1930
|
}
|
|
1800
1931
|
async function compileRemote(client, jobDir, loaded) {
|
|
1801
1932
|
if (!loaded.doc) throw new Error("compileRemote requires a parsed timeline");
|
|
1802
|
-
const silences = await detectSilencesForDoc(jobDir, loaded.doc);
|
|
1803
1933
|
const assets = statAssetsForDoc(jobDir, loaded.doc);
|
|
1804
1934
|
const res = await client.compileRemote({
|
|
1805
1935
|
timeline: loaded.raw,
|
|
1806
|
-
...Object.keys(silences).length > 0 ? { silences_by_source: silences } : {},
|
|
1807
1936
|
...Object.keys(assets).length > 0 ? { assets_present: assets } : {}
|
|
1808
1937
|
});
|
|
1809
|
-
const dir =
|
|
1810
|
-
|
|
1938
|
+
const dir = join6(jobDir, "resolved");
|
|
1939
|
+
mkdirSync5(dir, { recursive: true });
|
|
1811
1940
|
const out = { ok: res.ok === true, diagnosticsPath: diagnosticsPath2(jobDir), response: res };
|
|
1812
|
-
|
|
1941
|
+
writeFileSync4(out.diagnosticsPath, JSON.stringify(res.diagnostics ?? { errors: [], warnings: [], dropped: [] }, null, 2) + "\n", "utf8");
|
|
1813
1942
|
if (res.ok === true && res.plan !== void 0 && res.plan !== null) {
|
|
1814
1943
|
out.planPath = planPath2(jobDir);
|
|
1815
|
-
|
|
1944
|
+
writeFileSync4(out.planPath, JSON.stringify(res.plan, null, 2) + "\n", "utf8");
|
|
1816
1945
|
}
|
|
1817
1946
|
if (res.ok === true && res.legacy_edl !== void 0 && res.legacy_edl !== null) {
|
|
1818
|
-
out.edlPath =
|
|
1819
|
-
|
|
1947
|
+
out.edlPath = join6(jobDir, "edl.json");
|
|
1948
|
+
writeFileSync4(out.edlPath, JSON.stringify(res.legacy_edl, null, 2) + "\n", "utf8");
|
|
1820
1949
|
}
|
|
1821
1950
|
return out;
|
|
1822
1951
|
}
|
|
1823
1952
|
|
|
1824
1953
|
// src/cloud/verify2.ts
|
|
1825
|
-
import { existsSync as
|
|
1826
|
-
import { join as
|
|
1954
|
+
import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
|
|
1955
|
+
import { join as join7 } from "path";
|
|
1827
1956
|
async function verifyCloud(client, jobDir, opts = {}) {
|
|
1828
1957
|
const verbose = opts.verbose !== false;
|
|
1829
1958
|
const log = (line) => {
|
|
1830
1959
|
if (verbose) process.stderr.write(line + "\n");
|
|
1831
1960
|
};
|
|
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
1961
|
const { runId } = await client.verifyStart({
|
|
1851
|
-
wav_path: wavPath,
|
|
1852
|
-
preview_duration_s: durationS,
|
|
1853
1962
|
...opts.full ? { mode: "full" } : {},
|
|
1854
|
-
...opts.windowS !== void 0 ? { window_s: opts.windowS } : {}
|
|
1855
|
-
...edl !== void 0 ? { edl } : {}
|
|
1963
|
+
...opts.windowS !== void 0 ? { window_s: opts.windowS } : {}
|
|
1856
1964
|
});
|
|
1857
1965
|
log(` verify run: ${runId}`);
|
|
1858
1966
|
const state = await pollRun(client, runId, {
|
|
@@ -1861,12 +1969,12 @@ async function verifyCloud(client, jobDir, opts = {}) {
|
|
|
1861
1969
|
});
|
|
1862
1970
|
assertRunCompleted(state, "verify");
|
|
1863
1971
|
await pullFiles(client, jobDir, { only: ["verify.json"] });
|
|
1864
|
-
const verifyPath =
|
|
1972
|
+
const verifyPath = join7(jobDir, "verify.json");
|
|
1865
1973
|
let verdict = null;
|
|
1866
1974
|
let findings = null;
|
|
1867
|
-
if (
|
|
1975
|
+
if (existsSync6(verifyPath)) {
|
|
1868
1976
|
try {
|
|
1869
|
-
const v = JSON.parse(
|
|
1977
|
+
const v = JSON.parse(readFileSync5(verifyPath, "utf8"));
|
|
1870
1978
|
verdict = typeof v.verdict === "string" ? v.verdict : null;
|
|
1871
1979
|
findings = Array.isArray(v.joins) ? v.joins.length : null;
|
|
1872
1980
|
} catch {
|
|
@@ -1875,302 +1983,128 @@ async function verifyCloud(client, jobDir, opts = {}) {
|
|
|
1875
1983
|
return { runId, verifyPath, verdict, findings };
|
|
1876
1984
|
}
|
|
1877
1985
|
|
|
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
|
-
);
|
|
1986
|
+
// src/cloud/qa.ts
|
|
1987
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
1988
|
+
import { join as join8 } from "path";
|
|
1989
|
+
function qaDir(jobDir) {
|
|
1990
|
+
return jobDir ? join8(jobDir, "qa") : join8(process.cwd(), "qa");
|
|
1991
|
+
}
|
|
1992
|
+
function tag(start, end) {
|
|
1993
|
+
return `${start}_${end}`;
|
|
1994
|
+
}
|
|
1995
|
+
function framesPngPath(dir, start, end, suffix = "") {
|
|
1996
|
+
return join8(dir, `frames_${tag(start, end)}${suffix}.png`);
|
|
1997
|
+
}
|
|
1998
|
+
function waveformPngPath(dir, start, end) {
|
|
1999
|
+
return join8(dir, `waveform_${tag(start, end)}.png`);
|
|
2000
|
+
}
|
|
2001
|
+
function inspectPngPath(dir, start, end, suffix = "") {
|
|
2002
|
+
return join8(dir, `inspect_${tag(start, end)}${suffix}.png`);
|
|
2003
|
+
}
|
|
2004
|
+
function waveformWordsPath(dir, start, end) {
|
|
2005
|
+
return join8(dir, `waveform_${tag(start, end)}.words.json`);
|
|
2006
|
+
}
|
|
2007
|
+
function readPlanSegments(jobDir) {
|
|
2008
|
+
const path = join8(jobDir, "resolved", "plan.json");
|
|
2009
|
+
if (!existsSync7(path)) {
|
|
2010
|
+
throw new Error(`no compiled plan at ${path} \u2014 run \`video-editing timeline compile\` first`);
|
|
2011
|
+
}
|
|
2012
|
+
let raw;
|
|
2013
|
+
try {
|
|
2014
|
+
raw = JSON.parse(readFileSync6(path, "utf8"));
|
|
2015
|
+
} catch (err) {
|
|
2016
|
+
throw new Error(`resolved/plan.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
2017
|
+
}
|
|
2018
|
+
const segments = raw?.segments;
|
|
2019
|
+
if (!Array.isArray(segments) || segments.length === 0) {
|
|
2020
|
+
throw new Error(`resolved/plan.json has no segments[]: ${path}`);
|
|
2021
|
+
}
|
|
2022
|
+
return segments.map((s, i) => {
|
|
2023
|
+
const r = s;
|
|
2024
|
+
const seg = {
|
|
2025
|
+
srcStart: Number(r.srcStart ?? NaN),
|
|
2026
|
+
srcEnd: Number(r.srcEnd ?? NaN),
|
|
2027
|
+
outStart: Number(r.outStart ?? NaN),
|
|
2028
|
+
outEnd: Number(r.outEnd ?? NaN)
|
|
2029
|
+
};
|
|
2030
|
+
if (![seg.srcStart, seg.srcEnd, seg.outStart, seg.outEnd].every(Number.isFinite)) {
|
|
2031
|
+
throw new Error(`plan segment ${i} is missing src/out times: ${JSON.stringify(s)}`);
|
|
1968
2032
|
}
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2033
|
+
return seg;
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
var round3 = (n) => Math.round(n * 1e3) / 1e3;
|
|
2037
|
+
var SUFFIXES = "abcdefghijklmnopqrstuvwxyz";
|
|
2038
|
+
function suffixed(windows) {
|
|
2039
|
+
return windows.map((w, i) => ({
|
|
2040
|
+
start: round3(w.start),
|
|
2041
|
+
end: round3(w.end),
|
|
2042
|
+
suffix: windows.length > 1 ? `-${SUFFIXES[i] ?? String(i)}` : ""
|
|
2043
|
+
}));
|
|
2044
|
+
}
|
|
2045
|
+
function joinSourceWindows(segments, joinIndex, spanS) {
|
|
2046
|
+
if (!Number.isInteger(joinIndex) || joinIndex < 1 || joinIndex > segments.length - 1) {
|
|
2047
|
+
throw new Error(
|
|
2048
|
+
`--join must be a 1-based join index between 1 and ${segments.length - 1} (the plan has ${segments.length} segment(s))`
|
|
1972
2049
|
);
|
|
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
2050
|
}
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2051
|
+
if (!(spanS > 0)) throw new Error("--span must be > 0 seconds");
|
|
2052
|
+
const a = segments[joinIndex - 1];
|
|
2053
|
+
const b = segments[joinIndex];
|
|
2054
|
+
return suffixed([
|
|
2055
|
+
{ start: Math.max(a.srcStart, a.srcEnd - spanS), end: a.srcEnd },
|
|
2056
|
+
{ start: b.srcStart, end: Math.min(b.srcEnd, b.srcStart + spanS) }
|
|
2057
|
+
]);
|
|
2058
|
+
}
|
|
2059
|
+
function joinSpanWindow(segments, joinIndex, spanS) {
|
|
2060
|
+
if (!Number.isInteger(joinIndex) || joinIndex < 1 || joinIndex > segments.length - 1) {
|
|
2061
|
+
throw new Error(
|
|
2062
|
+
`--join must be a 1-based join index between 1 and ${segments.length - 1} (the plan has ${segments.length} segment(s))`
|
|
2063
|
+
);
|
|
2039
2064
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
return result;
|
|
2045
|
-
}
|
|
2046
|
-
|
|
2047
|
-
// src/cloud/qa-local.ts
|
|
2048
|
-
import { existsSync as existsSync9, mkdirSync as mkdirSync8, readdirSync as readdirSync2, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
2049
|
-
import { join as join11 } from "path";
|
|
2050
|
-
function qaFramesArgs(win, opts = {}) {
|
|
2051
|
-
const dur = Math.max(0.05, win.end - win.start);
|
|
2052
|
-
const frames = Math.max(1, Math.round(opts.frames ?? 10));
|
|
2053
|
-
const cols = Math.max(1, Math.round(opts.cols ?? 5));
|
|
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
|
-
];
|
|
2065
|
+
if (!(spanS > 0)) throw new Error("--span must be > 0 seconds");
|
|
2066
|
+
const a = segments[joinIndex - 1];
|
|
2067
|
+
const b = segments[joinIndex];
|
|
2068
|
+
return { start: round3(Math.max(0, a.srcEnd - spanS)), end: round3(b.srcStart + spanS), suffix: "" };
|
|
2075
2069
|
}
|
|
2076
|
-
function
|
|
2077
|
-
|
|
2078
|
-
const
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
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 });
|
|
2070
|
+
function outWindowToSourceWindows(segments, outStart, outEnd) {
|
|
2071
|
+
if (!(outEnd > outStart)) throw new Error("--out-end must be greater than --out-start");
|
|
2072
|
+
const windows = [];
|
|
2073
|
+
for (const seg of segments) {
|
|
2074
|
+
const s = Math.max(outStart, seg.outStart);
|
|
2075
|
+
const e = Math.min(outEnd, seg.outEnd);
|
|
2076
|
+
if (e - s <= 5e-4) continue;
|
|
2077
|
+
windows.push({ start: seg.srcStart + (s - seg.outStart), end: seg.srcStart + (e - seg.outStart) });
|
|
2109
2078
|
}
|
|
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;
|
|
2079
|
+
if (windows.length === 0) {
|
|
2080
|
+
const total = segments.length ? Math.max(...segments.map((s) => s.outEnd)) : 0;
|
|
2081
|
+
throw new Error(`output window ${outStart}s\u2013${outEnd}s does not overlap the plan (output duration ${total}s)`);
|
|
2148
2082
|
}
|
|
2149
|
-
return
|
|
2083
|
+
return suffixed(windows);
|
|
2150
2084
|
}
|
|
2151
2085
|
|
|
2152
2086
|
// src/cloud/review2.ts
|
|
2153
2087
|
import { createHash as createHash2 } from "crypto";
|
|
2154
|
-
import { existsSync as
|
|
2155
|
-
import { basename, isAbsolute as isAbsolute2, join as
|
|
2088
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
2089
|
+
import { basename, isAbsolute as isAbsolute2, join as join9 } from "path";
|
|
2156
2090
|
function reviewDir2(jobDir) {
|
|
2157
|
-
return
|
|
2091
|
+
return join9(jobDir, "review");
|
|
2158
2092
|
}
|
|
2159
2093
|
function roundJsonPath2(jobDir) {
|
|
2160
|
-
return
|
|
2094
|
+
return join9(reviewDir2(jobDir), "round.json");
|
|
2161
2095
|
}
|
|
2162
2096
|
function instructionPath2(jobDir) {
|
|
2163
|
-
return
|
|
2097
|
+
return join9(reviewDir2(jobDir), "instruction.md");
|
|
2164
2098
|
}
|
|
2165
2099
|
function assetsCachePath2(jobDir) {
|
|
2166
|
-
return
|
|
2100
|
+
return join9(reviewDir2(jobDir), "assets.json");
|
|
2167
2101
|
}
|
|
2168
2102
|
async function pullReview2(client, jobDir, opts = {}) {
|
|
2169
2103
|
const session = await client.getReview();
|
|
2170
|
-
|
|
2104
|
+
mkdirSync6(reviewDir2(jobDir), { recursive: true });
|
|
2171
2105
|
const round = session.activeRound;
|
|
2172
2106
|
if (round) {
|
|
2173
|
-
|
|
2107
|
+
writeFileSync5(
|
|
2174
2108
|
roundJsonPath2(jobDir),
|
|
2175
2109
|
JSON.stringify(
|
|
2176
2110
|
{
|
|
@@ -2188,20 +2122,20 @@ async function pullReview2(client, jobDir, opts = {}) {
|
|
|
2188
2122
|
) + "\n",
|
|
2189
2123
|
"utf8"
|
|
2190
2124
|
);
|
|
2191
|
-
|
|
2125
|
+
writeFileSync5(instructionPath2(jobDir), round.instruction + "\n", "utf8");
|
|
2192
2126
|
} else {
|
|
2193
|
-
|
|
2127
|
+
writeFileSync5(instructionPath2(jobDir), "No active review round for this video.\n", "utf8");
|
|
2194
2128
|
}
|
|
2195
2129
|
let wroteTimeline = false;
|
|
2196
2130
|
const tlPath = timelineJsonPath2(jobDir);
|
|
2197
|
-
if (session.timeline !== null && session.timeline !== void 0 && !
|
|
2198
|
-
|
|
2131
|
+
if (session.timeline !== null && session.timeline !== void 0 && !existsSync8(tlPath)) {
|
|
2132
|
+
writeFileSync5(tlPath, JSON.stringify(session.timeline, null, 2) + "\n", "utf8");
|
|
2199
2133
|
wroteTimeline = true;
|
|
2200
2134
|
}
|
|
2201
2135
|
let downloadedSource = null;
|
|
2202
2136
|
if (opts.withSource && session.sourceUrl) {
|
|
2203
|
-
const dest =
|
|
2204
|
-
if (!
|
|
2137
|
+
const dest = join9(jobDir, "source.mp4");
|
|
2138
|
+
if (!existsSync8(dest)) {
|
|
2205
2139
|
const dl = await client.download(session.sourceUrl, dest);
|
|
2206
2140
|
downloadedSource = dl.path;
|
|
2207
2141
|
}
|
|
@@ -2211,9 +2145,9 @@ async function pullReview2(client, jobDir, opts = {}) {
|
|
|
2211
2145
|
function resolveRunId2(jobDir, flagOverride) {
|
|
2212
2146
|
if (typeof flagOverride === "string" && flagOverride.trim() !== "") return flagOverride.trim();
|
|
2213
2147
|
const path = roundJsonPath2(jobDir);
|
|
2214
|
-
if (
|
|
2148
|
+
if (existsSync8(path)) {
|
|
2215
2149
|
try {
|
|
2216
|
-
const runId = JSON.parse(
|
|
2150
|
+
const runId = JSON.parse(readFileSync7(path, "utf8")).run_id;
|
|
2217
2151
|
if (typeof runId === "string" && runId !== "") return runId;
|
|
2218
2152
|
} catch {
|
|
2219
2153
|
}
|
|
@@ -2273,8 +2207,8 @@ async function syncAssets2(client, jobDir, plan) {
|
|
|
2273
2207
|
if (refs.length === 0) return {};
|
|
2274
2208
|
const paths = /* @__PURE__ */ new Map();
|
|
2275
2209
|
for (const ref of refs) {
|
|
2276
|
-
const abs = isAbsolute2(ref) ? ref :
|
|
2277
|
-
if (!
|
|
2210
|
+
const abs = isAbsolute2(ref) ? ref : join9(jobDir, ref);
|
|
2211
|
+
if (!existsSync8(abs)) {
|
|
2278
2212
|
throw new Error(
|
|
2279
2213
|
`asset not found: ${ref} (expected at ${abs}) \u2014 asset refs are job-dir-relative paths; place the file before pushing`
|
|
2280
2214
|
);
|
|
@@ -2282,9 +2216,9 @@ async function syncAssets2(client, jobDir, plan) {
|
|
|
2282
2216
|
paths.set(ref, abs);
|
|
2283
2217
|
}
|
|
2284
2218
|
let cache = {};
|
|
2285
|
-
if (
|
|
2219
|
+
if (existsSync8(assetsCachePath2(jobDir))) {
|
|
2286
2220
|
try {
|
|
2287
|
-
const raw = JSON.parse(
|
|
2221
|
+
const raw = JSON.parse(readFileSync7(assetsCachePath2(jobDir), "utf8"));
|
|
2288
2222
|
for (const [ref, v] of Object.entries(raw ?? {})) {
|
|
2289
2223
|
const e = v;
|
|
2290
2224
|
if (e && typeof e.sha256 === "string" && typeof e.asset_id === "string") {
|
|
@@ -2298,7 +2232,7 @@ async function syncAssets2(client, jobDir, plan) {
|
|
|
2298
2232
|
const manifest = {};
|
|
2299
2233
|
let dirty = false;
|
|
2300
2234
|
for (const ref of refs) {
|
|
2301
|
-
const bytes =
|
|
2235
|
+
const bytes = readFileSync7(paths.get(ref));
|
|
2302
2236
|
const sha256 = "sha256:" + createHash2("sha256").update(bytes).digest("hex");
|
|
2303
2237
|
const hit = cache[ref];
|
|
2304
2238
|
if (hit !== void 0 && hit.sha256 === sha256) {
|
|
@@ -2313,8 +2247,8 @@ async function syncAssets2(client, jobDir, plan) {
|
|
|
2313
2247
|
dirty = true;
|
|
2314
2248
|
}
|
|
2315
2249
|
if (dirty) {
|
|
2316
|
-
|
|
2317
|
-
|
|
2250
|
+
mkdirSync6(reviewDir2(jobDir), { recursive: true });
|
|
2251
|
+
writeFileSync5(assetsCachePath2(jobDir), JSON.stringify(cache, null, 2) + "\n", "utf8");
|
|
2318
2252
|
}
|
|
2319
2253
|
return manifest;
|
|
2320
2254
|
}
|
|
@@ -2347,10 +2281,10 @@ async function pushTimeline2(client, jobDir, runId) {
|
|
|
2347
2281
|
return { ok: true, plan: compiled.plan, timelineHash: compiled.timelineHash, assets };
|
|
2348
2282
|
}
|
|
2349
2283
|
function readOutcomesFile2(path) {
|
|
2350
|
-
if (!
|
|
2284
|
+
if (!existsSync8(path)) throw new Error(`outcomes file not found: ${path}`);
|
|
2351
2285
|
let raw;
|
|
2352
2286
|
try {
|
|
2353
|
-
raw = JSON.parse(
|
|
2287
|
+
raw = JSON.parse(readFileSync7(path, "utf8"));
|
|
2354
2288
|
} catch (err) {
|
|
2355
2289
|
throw new Error(`outcomes file is not valid JSON: ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2356
2290
|
}
|
|
@@ -2387,16 +2321,16 @@ async function pushComplete2(client, jobDir, runId, opts) {
|
|
|
2387
2321
|
}
|
|
2388
2322
|
|
|
2389
2323
|
// src/public/config.ts
|
|
2390
|
-
import { existsSync as
|
|
2324
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "fs";
|
|
2391
2325
|
import { homedir as homedir2 } from "os";
|
|
2392
|
-
import { join as
|
|
2326
|
+
import { join as join10 } from "path";
|
|
2393
2327
|
function publicConfigDir() {
|
|
2394
2328
|
const xdg = process.env.XDG_CONFIG_HOME;
|
|
2395
|
-
const base = xdg && xdg.trim() ? xdg :
|
|
2396
|
-
return
|
|
2329
|
+
const base = xdg && xdg.trim() ? xdg : join10(homedir2(), ".config");
|
|
2330
|
+
return join10(base, "video-editing");
|
|
2397
2331
|
}
|
|
2398
2332
|
function publicConfigEnvPath() {
|
|
2399
|
-
return
|
|
2333
|
+
return join10(publicConfigDir(), ".env");
|
|
2400
2334
|
}
|
|
2401
2335
|
function parseEnvLines(text) {
|
|
2402
2336
|
return text.split(/\r?\n/).map((raw) => {
|
|
@@ -2412,10 +2346,10 @@ function parseEnvLines(text) {
|
|
|
2412
2346
|
var MANAGED_KEYS = ["CLIPREADY_API_KEY", "CLIPREADY_API_BASE"];
|
|
2413
2347
|
function loadPublicConfigEnv(env = process.env) {
|
|
2414
2348
|
const path = publicConfigEnvPath();
|
|
2415
|
-
if (!
|
|
2349
|
+
if (!existsSync9(path)) return;
|
|
2416
2350
|
let text;
|
|
2417
2351
|
try {
|
|
2418
|
-
text =
|
|
2352
|
+
text = readFileSync8(path, "utf8");
|
|
2419
2353
|
} catch {
|
|
2420
2354
|
return;
|
|
2421
2355
|
}
|
|
@@ -2425,22 +2359,31 @@ function loadPublicConfigEnv(env = process.env) {
|
|
|
2425
2359
|
if (!env[line.key] || !String(env[line.key]).trim()) env[line.key] = line.value;
|
|
2426
2360
|
}
|
|
2427
2361
|
}
|
|
2362
|
+
function removePublicConfigKey(key) {
|
|
2363
|
+
const path = publicConfigEnvPath();
|
|
2364
|
+
if (!existsSync9(path)) return path;
|
|
2365
|
+
const lines = readFileSync8(path, "utf8").split(/\r?\n/).filter((l) => l.trim() !== "").filter((l) => {
|
|
2366
|
+
const eq = l.indexOf("=");
|
|
2367
|
+
return eq < 0 || l.slice(0, eq).trim() !== key;
|
|
2368
|
+
});
|
|
2369
|
+
writeFileSync6(path, lines.length ? lines.join("\n") + "\n" : "", { mode: 384 });
|
|
2370
|
+
return path;
|
|
2371
|
+
}
|
|
2428
2372
|
function savePublicConfigKey(key, value) {
|
|
2429
|
-
|
|
2373
|
+
mkdirSync7(publicConfigDir(), { recursive: true });
|
|
2430
2374
|
const path = publicConfigEnvPath();
|
|
2431
|
-
const existing =
|
|
2375
|
+
const existing = existsSync9(path) ? readFileSync8(path, "utf8") : "";
|
|
2432
2376
|
const lines = existing.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
2433
2377
|
const kept = lines.filter((l) => {
|
|
2434
2378
|
const eq = l.indexOf("=");
|
|
2435
2379
|
return eq < 0 || l.slice(0, eq).trim() !== key;
|
|
2436
2380
|
});
|
|
2437
2381
|
kept.push(`${key}=${value}`);
|
|
2438
|
-
|
|
2382
|
+
writeFileSync6(path, kept.join("\n") + "\n", { mode: 384 });
|
|
2439
2383
|
return path;
|
|
2440
2384
|
}
|
|
2441
2385
|
|
|
2442
2386
|
export {
|
|
2443
|
-
requireFfmpeg,
|
|
2444
2387
|
DEFAULT_TIMEOUT_MS,
|
|
2445
2388
|
DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
2446
2389
|
resolveApiBase,
|
|
@@ -2455,9 +2398,13 @@ export {
|
|
|
2455
2398
|
meUrl,
|
|
2456
2399
|
projectsUrl,
|
|
2457
2400
|
projectVideosUrl,
|
|
2401
|
+
webVideoUrl,
|
|
2458
2402
|
runUrl,
|
|
2403
|
+
multipartUrl,
|
|
2404
|
+
multipartRelayUrl,
|
|
2459
2405
|
filesUrl,
|
|
2460
2406
|
transcribeUrl,
|
|
2407
|
+
ingestUrl,
|
|
2461
2408
|
compileUrl,
|
|
2462
2409
|
resolveUrl,
|
|
2463
2410
|
verifyUrl,
|
|
@@ -2483,7 +2430,13 @@ export {
|
|
|
2483
2430
|
writeJobFile,
|
|
2484
2431
|
readJobFile,
|
|
2485
2432
|
videoIdFromJob,
|
|
2486
|
-
|
|
2433
|
+
MULTIPART_THRESHOLD_BYTES,
|
|
2434
|
+
RELAY_DIRECT_ATTEMPTS,
|
|
2435
|
+
RELAY_SUBCHUNK_BYTES,
|
|
2436
|
+
shouldMultipart,
|
|
2437
|
+
bytesSource,
|
|
2438
|
+
openFileSource,
|
|
2439
|
+
uploadMultipart,
|
|
2487
2440
|
INLINE_MAX_BYTES,
|
|
2488
2441
|
sha256Hex,
|
|
2489
2442
|
sameSha,
|
|
@@ -2492,18 +2445,18 @@ export {
|
|
|
2492
2445
|
vfsContentType,
|
|
2493
2446
|
pullFiles,
|
|
2494
2447
|
pushFiles,
|
|
2448
|
+
uploadVfsPayload,
|
|
2495
2449
|
uploadVfsBytes,
|
|
2496
2450
|
MAX_RUN_POLL_MS,
|
|
2497
2451
|
formatRunEvent,
|
|
2498
2452
|
pollRun,
|
|
2499
2453
|
assertRunCompleted,
|
|
2454
|
+
sanitizeStem,
|
|
2455
|
+
ingestArtifactSet,
|
|
2456
|
+
readProbeDuration,
|
|
2457
|
+
initCloudJob,
|
|
2500
2458
|
transcribeArtifactSet,
|
|
2501
|
-
extractGuardedWav,
|
|
2502
2459
|
transcribeCloud,
|
|
2503
|
-
DEFAULT_DESILENCE,
|
|
2504
|
-
silenceDetectArgs,
|
|
2505
|
-
parseSilenceDetect,
|
|
2506
|
-
detectSilences,
|
|
2507
2460
|
CAPTION_STYLE_DEFAULTS,
|
|
2508
2461
|
timelineV2Schema,
|
|
2509
2462
|
emptyDiagnostics,
|
|
@@ -2517,18 +2470,18 @@ export {
|
|
|
2517
2470
|
diagnosticsPath2,
|
|
2518
2471
|
loadTimeline2,
|
|
2519
2472
|
validateLocal,
|
|
2520
|
-
detectSilencesForDoc,
|
|
2521
2473
|
statAssetsForDoc,
|
|
2522
2474
|
compileRemote,
|
|
2523
2475
|
verifyCloud,
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2476
|
+
qaDir,
|
|
2477
|
+
framesPngPath,
|
|
2478
|
+
waveformPngPath,
|
|
2479
|
+
inspectPngPath,
|
|
2480
|
+
waveformWordsPath,
|
|
2481
|
+
readPlanSegments,
|
|
2482
|
+
joinSourceWindows,
|
|
2483
|
+
joinSpanWindow,
|
|
2484
|
+
outWindowToSourceWindows,
|
|
2532
2485
|
reviewDir2,
|
|
2533
2486
|
roundJsonPath2,
|
|
2534
2487
|
instructionPath2,
|
|
@@ -2545,5 +2498,6 @@ export {
|
|
|
2545
2498
|
publicConfigDir,
|
|
2546
2499
|
publicConfigEnvPath,
|
|
2547
2500
|
loadPublicConfigEnv,
|
|
2501
|
+
removePublicConfigKey,
|
|
2548
2502
|
savePublicConfigKey
|
|
2549
2503
|
};
|