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
package/dist/cli.js
CHANGED
|
@@ -3,14 +3,18 @@ import {
|
|
|
3
3
|
CloudClient,
|
|
4
4
|
REVIEW_STAGES2,
|
|
5
5
|
compileRemote,
|
|
6
|
-
composeCloud,
|
|
7
6
|
defaultRenderOut,
|
|
7
|
+
framesPngPath,
|
|
8
8
|
initCloudJob,
|
|
9
|
+
inspectPngPath,
|
|
9
10
|
installSkill,
|
|
10
11
|
instructionPath2,
|
|
11
12
|
jobJsonPath,
|
|
13
|
+
joinSourceWindows,
|
|
14
|
+
joinSpanWindow,
|
|
12
15
|
loadPublicConfigEnv,
|
|
13
16
|
loadTimeline2,
|
|
17
|
+
outWindowToSourceWindows,
|
|
14
18
|
pollRender,
|
|
15
19
|
publicConfigEnvPath,
|
|
16
20
|
pullFiles,
|
|
@@ -19,22 +23,28 @@ import {
|
|
|
19
23
|
pushFiles,
|
|
20
24
|
pushStage2,
|
|
21
25
|
pushTimeline2,
|
|
22
|
-
|
|
23
|
-
qaWaveformLocal,
|
|
26
|
+
qaDir,
|
|
24
27
|
readJobFile,
|
|
25
|
-
|
|
28
|
+
readPlanSegments,
|
|
29
|
+
removePublicConfigKey,
|
|
30
|
+
resolveApiBase,
|
|
31
|
+
resolveApiKey,
|
|
26
32
|
resolveConfig,
|
|
27
33
|
resolveRunId2,
|
|
28
34
|
savePublicConfigKey,
|
|
35
|
+
stripTrailingSlash,
|
|
29
36
|
timelineJsonPath2,
|
|
30
37
|
transcribeCloud,
|
|
31
38
|
validateLocal,
|
|
32
39
|
verifyCloud,
|
|
33
|
-
videoIdFromJob
|
|
34
|
-
|
|
40
|
+
videoIdFromJob,
|
|
41
|
+
waveformPngPath,
|
|
42
|
+
waveformWordsPath,
|
|
43
|
+
webVideoUrl
|
|
44
|
+
} from "./chunk-W4IIL2T2.js";
|
|
35
45
|
|
|
36
46
|
// src/public/cli.ts
|
|
37
|
-
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
47
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
38
48
|
import { join, resolve as resolvePath } from "path";
|
|
39
49
|
import { Command } from "commander";
|
|
40
50
|
loadPublicConfigEnv();
|
|
@@ -79,15 +89,14 @@ var pkg;
|
|
|
79
89
|
try {
|
|
80
90
|
pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
81
91
|
} catch {
|
|
82
|
-
pkg = { version: "
|
|
92
|
+
pkg = { version: "3.0.0" };
|
|
83
93
|
}
|
|
84
94
|
var program = new Command();
|
|
85
|
-
program.name("video-editing").description("
|
|
95
|
+
program.name("video-editing").description("Pure-API cloud CLI for editing talking-head reels with clipready: init (upload + server ingest/transcribe), timeline authoring, cloud compile/render/verify/QA, file sync, review rounds. No local media tools needed \u2014 just Node \u2265 20 and CLIPREADY_API_KEY.").version(pkg.version);
|
|
86
96
|
addCloudOptions(
|
|
87
|
-
program.command("init").description("Create
|
|
97
|
+
program.command("init").description("Create the cloud job end-to-end: stream the source up, run server-side ingest (probe + transcription + screening), pull the transcript/brief artifacts, write <job>/job.json. Covers what init+transcribe used to do \u2014 no separate transcribe needed.").requiredOption("--source <path>", "Source video path").option("--job-dir <path>", "Job directory (default ./job_<stem>)").option("--title <title>", "Project title (default: source stem)").option("--poll-interval <s>", "Ingest-run poll interval in seconds", "5").option("--json", "Print JSON")
|
|
88
98
|
).action(async (o) => {
|
|
89
99
|
await withCloudErrors(!!o.json, async () => {
|
|
90
|
-
requireFfmpeg();
|
|
91
100
|
const cfg = resolveConfig(cloudFlags(o), { requireVideoId: false });
|
|
92
101
|
const client = new CloudClient(cfg);
|
|
93
102
|
let res;
|
|
@@ -96,7 +105,7 @@ addCloudOptions(
|
|
|
96
105
|
source: o.source,
|
|
97
106
|
jobDir: o.jobDir,
|
|
98
107
|
title: o.title,
|
|
99
|
-
|
|
108
|
+
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
100
109
|
verbose: !o.json
|
|
101
110
|
});
|
|
102
111
|
} catch (err) {
|
|
@@ -107,43 +116,35 @@ Your credential was rejected \u2014 get your API key at ${cfg.base}/settings and
|
|
|
107
116
|
throw err;
|
|
108
117
|
}
|
|
109
118
|
if (o.json) {
|
|
110
|
-
console.log(JSON.stringify({ ok: true, job_dir: res.jobDir, job_json: res.jobJson, video_id: res.videoId, project_id: res.projectId, stem: res.stem, duration_s: res.durationS, uploaded: res.uploaded, uploaded_bytes: res.uploadedBytes }, null, 2));
|
|
119
|
+
console.log(JSON.stringify({ ok: true, job_dir: res.jobDir, job_json: res.jobJson, video_id: res.videoId, project_id: res.projectId, stem: res.stem, duration_s: res.durationS, uploaded: res.uploaded, uploaded_bytes: res.uploadedBytes, run_id: res.runId, pulled: res.pulled.pulled.map((p) => p.path), missing: res.pulled.missing, web_url: res.webUrl }, null, 2));
|
|
111
120
|
} else {
|
|
112
121
|
console.log(`job: ${res.jobDir}`);
|
|
113
122
|
console.log(`video_id: ${res.videoId}`);
|
|
114
123
|
console.log(`project_id: ${res.projectId}`);
|
|
115
124
|
console.log(`job.json: ${res.jobJson}`);
|
|
125
|
+
for (const p of res.pulled.pulled) console.log(` ${p.path} \u2192 ${p.localPath}${p.skipped ? " (unchanged)" : ""}`);
|
|
126
|
+
if (res.pulled.missing.length > 0) console.log(` missing on server: ${res.pulled.missing.join(", ")}`);
|
|
127
|
+
console.log(`web: ${res.webUrl}`);
|
|
116
128
|
}
|
|
117
129
|
});
|
|
118
130
|
});
|
|
119
131
|
addCloudOptions(
|
|
120
|
-
program.command("transcribe").description("
|
|
132
|
+
program.command("transcribe").description("Re-run/pull transcription: pull the transcript artifacts when they already exist server-side, else run server-side ingest and pull (init already did this once)").option("--job-dir <path>", "Job directory (default: cwd)").option("--poll-interval <s>", "Run poll interval in seconds", "5").option("--json", "Print JSON")
|
|
121
133
|
).action(async (o) => {
|
|
122
134
|
await withCloudErrors(!!o.json, async () => {
|
|
123
|
-
requireFfmpeg();
|
|
124
135
|
const jobDir = jobDirOf(o);
|
|
125
136
|
const cfg = jobConfig(o, jobDir);
|
|
126
137
|
const client = new CloudClient(cfg);
|
|
127
138
|
const job = readJobFile(jobDir);
|
|
128
|
-
let media = o.media ? resolvePath(o.media) : join(jobDir, "vertical_src.mp4");
|
|
129
|
-
if (!existsSync(media)) {
|
|
130
|
-
const alt = job ? join(jobDir, `${job.stem}.mp4`) : null;
|
|
131
|
-
if (alt && existsSync(alt)) media = alt;
|
|
132
|
-
else if (!o.media) throw new Error(`no local media found (looked for ${media}) \u2014 pass --media <path>`);
|
|
133
|
-
else throw new Error(`media not found: ${media}`);
|
|
134
|
-
}
|
|
135
139
|
const res = await transcribeCloud(client, jobDir, {
|
|
136
|
-
media,
|
|
137
140
|
stem: job?.stem ?? "source",
|
|
138
|
-
language: o.language,
|
|
139
|
-
numSpeakers: num(o.numSpeakers),
|
|
140
141
|
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
141
142
|
verbose: !o.json
|
|
142
143
|
});
|
|
143
144
|
if (o.json) {
|
|
144
|
-
console.log(JSON.stringify({ ok: true, run_id: res.runId,
|
|
145
|
+
console.log(JSON.stringify({ ok: true, run_id: res.runId, pulled: res.pulled.pulled.map((p) => p.path), missing: res.pulled.missing }, null, 2));
|
|
145
146
|
} else {
|
|
146
|
-
console.log(`run: ${res.runId}`);
|
|
147
|
+
if (res.runId) console.log(`run: ${res.runId}`);
|
|
147
148
|
for (const p of res.pulled.pulled) console.log(` ${p.path} \u2192 ${p.localPath}${p.skipped ? " (unchanged)" : ""}`);
|
|
148
149
|
if (res.pulled.missing.length > 0) console.log(` missing on server: ${res.pulled.missing.join(", ")}`);
|
|
149
150
|
}
|
|
@@ -214,7 +215,6 @@ addCloudOptions(
|
|
|
214
215
|
});
|
|
215
216
|
async function runRemoteCompile(o) {
|
|
216
217
|
const jobDir = jobDirOf(o);
|
|
217
|
-
requireFfmpeg();
|
|
218
218
|
const client = new CloudClient(jobConfig(o, jobDir));
|
|
219
219
|
const loaded = loadTimeline2(jobDir);
|
|
220
220
|
if (!loaded.doc) {
|
|
@@ -230,8 +230,10 @@ async function runRemoteCompile(o) {
|
|
|
230
230
|
}
|
|
231
231
|
const res = await compileRemote(client, jobDir, loaded);
|
|
232
232
|
const diagnostics = res.response.diagnostics;
|
|
233
|
+
const job = readJobFile(jobDir);
|
|
234
|
+
const webUrl = res.ok && job && job.project_id !== "" ? webVideoUrl(client.cfg.base, job.project_id, job.video_id) : null;
|
|
233
235
|
if (o.json) {
|
|
234
|
-
console.log(JSON.stringify({ ok: res.ok, plan: res.planPath ?? null, edl: res.edlPath ?? null, diagnostics: res.response.diagnostics ?? null, uses: res.response.uses ?? null }, null, 2));
|
|
236
|
+
console.log(JSON.stringify({ ok: res.ok, plan: res.planPath ?? null, edl: res.edlPath ?? null, diagnostics: res.response.diagnostics ?? null, uses: res.response.uses ?? null, web_url: webUrl }, null, 2));
|
|
235
237
|
} else {
|
|
236
238
|
if (res.ok && res.planPath) {
|
|
237
239
|
console.log(`plan: ${res.planPath}`);
|
|
@@ -243,6 +245,7 @@ async function runRemoteCompile(o) {
|
|
|
243
245
|
const d = e;
|
|
244
246
|
console.log(` [error] ${d.path ?? ""}: ${d.message ?? ""}`);
|
|
245
247
|
}
|
|
248
|
+
if (webUrl) console.log(`View & comment: ${webUrl}`);
|
|
246
249
|
}
|
|
247
250
|
if (!res.ok) {
|
|
248
251
|
const ambiguous = (diagnostics?.errors ?? []).some((e) => e.code === "anchor-ambiguous");
|
|
@@ -273,10 +276,9 @@ addCloudOptions(
|
|
|
273
276
|
});
|
|
274
277
|
});
|
|
275
278
|
addCloudOptions(
|
|
276
|
-
program.command("verify").description("Content-verify the rendered preview server-side
|
|
279
|
+
program.command("verify").description("Content-verify the rendered preview fully server-side (no upload): start the run, poll, pull verify.json").option("--job-dir <path>", "Job directory (default: cwd)").option("--full", "Verify the whole preview instead of per-join windows").option("--window <s>", "Per-join window length in seconds").option("--poll-interval <s>", "Run poll interval in seconds", "5").option("--json", "Print JSON")
|
|
277
280
|
).action(async (o) => {
|
|
278
281
|
await withCloudErrors(!!o.json, async () => {
|
|
279
|
-
requireFfmpeg();
|
|
280
282
|
const jobDir = jobDirOf(o);
|
|
281
283
|
const client = new CloudClient(jobConfig(o, jobDir));
|
|
282
284
|
const res = await verifyCloud(client, jobDir, {
|
|
@@ -294,84 +296,99 @@ addCloudOptions(
|
|
|
294
296
|
if (res.verdict === "needs-fixes") process.exitCode = 2;
|
|
295
297
|
});
|
|
296
298
|
});
|
|
299
|
+
var qaCmd = program.command("qa").description("Cloud QC: server-rendered filmstrip frames, waveform (word-timing sidecar) + the combined inspect composite; windows in source seconds, or plan-addressed via --join / --out-start");
|
|
300
|
+
function framesWindows(jobDir, o, joinMode = "split") {
|
|
301
|
+
const start = num(o.start);
|
|
302
|
+
const end = num(o.end);
|
|
303
|
+
const joinIdx = num(o.join);
|
|
304
|
+
const outStart = num(o.outStart);
|
|
305
|
+
const outEnd = num(o.outEnd);
|
|
306
|
+
const modes = [start !== void 0 || end !== void 0, joinIdx !== void 0, outStart !== void 0 || outEnd !== void 0];
|
|
307
|
+
if (modes.filter(Boolean).length !== 1) {
|
|
308
|
+
throw new Error("pass exactly one addressing mode: --start/--end (source s), --join <i>, or --out-start/--out-end (output s)");
|
|
309
|
+
}
|
|
310
|
+
if (joinIdx !== void 0) {
|
|
311
|
+
const segs = readPlanSegments(jobDir);
|
|
312
|
+
const span = num(o.span) ?? 2;
|
|
313
|
+
return joinMode === "span" ? [joinSpanWindow(segs, joinIdx, span)] : joinSourceWindows(segs, joinIdx, span);
|
|
314
|
+
}
|
|
315
|
+
if (outStart !== void 0 || outEnd !== void 0) {
|
|
316
|
+
if (outStart === void 0 || outEnd === void 0) throw new Error("--out-start and --out-end are both required");
|
|
317
|
+
return outWindowToSourceWindows(readPlanSegments(jobDir), outStart, outEnd);
|
|
318
|
+
}
|
|
319
|
+
if (start === void 0 || end === void 0 || end <= start) throw new Error("--start and --end must be numbers with end > start");
|
|
320
|
+
return [{ start, end, suffix: "" }];
|
|
321
|
+
}
|
|
297
322
|
addCloudOptions(
|
|
298
|
-
|
|
323
|
+
qaCmd.command("frames").description("Server-render a filmstrip PNG per window and download it under <job>/qa").option("--start <s>", "Window start (SOURCE seconds)").option("--end <s>", "Window end (SOURCE seconds)").option("--join <i>", "Plan join index (1-based): filmstrips for the last/first --span seconds of the two segments meeting there (-a tail, -b head)").option("--span <s>", "Seconds per side for --join", "2").option("--out-start <s>", "Window start in OUTPUT seconds (mapped through resolved/plan.json)").option("--out-end <s>", "Window end in OUTPUT seconds").option("--cols <n>", "Grid columns").option("--interval <s>", "Seconds between frames").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
|
|
299
324
|
).action(async (o) => {
|
|
300
325
|
await withCloudErrors(!!o.json, async () => {
|
|
301
|
-
requireFfmpeg();
|
|
302
326
|
const jobDir = jobDirOf(o);
|
|
303
327
|
const client = new CloudClient(jobConfig(o, jobDir));
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
if (o.render && !res.rendered) {
|
|
313
|
-
process.exitCode = res.renderExit ?? 1;
|
|
314
|
-
process.stderr.write(`hyperframes render failed with exit ${res.renderExit}
|
|
315
|
-
`);
|
|
316
|
-
return;
|
|
328
|
+
const windows = framesWindows(jobDir, o);
|
|
329
|
+
const dir = qaDir(jobDir);
|
|
330
|
+
const outputs = [];
|
|
331
|
+
for (const w of windows) {
|
|
332
|
+
const res = await client.qaFrames({ start: w.start, end: w.end, cols: num(o.cols), interval: num(o.interval) });
|
|
333
|
+
const png = framesPngPath(dir, w.start, w.end, w.suffix);
|
|
334
|
+
const dl = await client.download(res.url, png);
|
|
335
|
+
outputs.push({ png: dl.path, width: res.width, height: res.height, frames: res.frames, tStart: res.tStart, tEnd: res.tEnd });
|
|
317
336
|
}
|
|
318
337
|
if (o.json) {
|
|
319
|
-
console.log(JSON.stringify({ ok: true,
|
|
338
|
+
console.log(JSON.stringify({ ok: true, frames: outputs.map((r) => ({ png: r.png, width: r.width, height: r.height, frames: r.frames, t_start: r.tStart, t_end: r.tEnd })) }, null, 2));
|
|
320
339
|
} else {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
340
|
+
for (const r of outputs) {
|
|
341
|
+
console.log(`frames: ${r.png}`);
|
|
342
|
+
console.log(`grid: ${r.frames} frame(s), ${r.width}x${r.height}, t ${r.tStart}s\u2013${r.tEnd}s`);
|
|
343
|
+
}
|
|
324
344
|
}
|
|
325
345
|
});
|
|
326
346
|
});
|
|
327
|
-
var qaCmd = program.command("qa").description("Local QC: filmstrip frames (ffmpeg tile) + waveform (showwavespic) with a word-timing sidecar");
|
|
328
|
-
function qaVideoPath(jobDir, which) {
|
|
329
|
-
const video = which === "vertical_src" ? join(jobDir, "vertical_src.mp4") : join(jobDir, "preview.mp4");
|
|
330
|
-
if (!existsSync(video)) throw new Error(`video not found: ${video}`);
|
|
331
|
-
return video;
|
|
332
|
-
}
|
|
333
347
|
addCloudOptions(
|
|
334
|
-
qaCmd.command("
|
|
348
|
+
qaCmd.command("inspect").description("Server-render the combined close-look composite (filmstrip + waveform + word labels + red cut shading from the compiled plan) per window and download it under <job>/qa").option("--start <s>", "Window start (SOURCE seconds)").option("--end <s>", "Window end (SOURCE seconds)").option("--join <i>", "Plan join index (1-based): ONE continuous composite spanning the cut \u2014 --span s of kept tail, the red-shaded cut region, --span s of kept head").option("--span <s>", "Seconds per side for --join", "2").option("--out-start <s>", "Window start in OUTPUT seconds (mapped through resolved/plan.json)").option("--out-end <s>", "Window end in OUTPUT seconds").option("--n-frames <n>", "Filmstrip frame count", "10").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
|
|
335
349
|
).action(async (o) => {
|
|
336
350
|
await withCloudErrors(!!o.json, async () => {
|
|
337
|
-
requireFfmpeg();
|
|
338
351
|
const jobDir = jobDirOf(o);
|
|
339
|
-
jobConfig(o, jobDir
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
start,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
else
|
|
352
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
353
|
+
const windows = framesWindows(jobDir, o, "span");
|
|
354
|
+
const dir = qaDir(jobDir);
|
|
355
|
+
const outputs = [];
|
|
356
|
+
for (const w of windows) {
|
|
357
|
+
const res = await client.qaInspect({ start: w.start, end: w.end, nFrames: num(o.nFrames) });
|
|
358
|
+
const png = inspectPngPath(dir, w.start, w.end, w.suffix);
|
|
359
|
+
const dl = await client.download(res.url, png);
|
|
360
|
+
outputs.push({ png: dl.path, tStart: res.tStart, tEnd: res.tEnd, cuts: res.cuts, cached: res.cached });
|
|
361
|
+
}
|
|
362
|
+
if (o.json) {
|
|
363
|
+
console.log(JSON.stringify({ ok: true, inspect: outputs.map((r) => ({ png: r.png, t_start: r.tStart, t_end: r.tEnd, cuts: r.cuts, cached: r.cached })) }, null, 2));
|
|
364
|
+
} else {
|
|
365
|
+
for (const r of outputs) {
|
|
366
|
+
console.log(`inspect: ${r.png}`);
|
|
367
|
+
console.log(`window: t ${r.tStart}s\u2013${r.tEnd}s, ${r.cuts.length} cut(s) shaded${r.cached ? " (cached)" : ""}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
352
370
|
});
|
|
353
371
|
});
|
|
354
372
|
addCloudOptions(
|
|
355
|
-
qaCmd.command("waveform").description("
|
|
373
|
+
qaCmd.command("waveform").description("Server-render a waveform PNG + word-timing sidecar for a source-time window").requiredOption("--start <s>", "Window start (SOURCE seconds)").requiredOption("--end <s>", "Window end (SOURCE seconds)").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
|
|
356
374
|
).action(async (o) => {
|
|
357
375
|
await withCloudErrors(!!o.json, async () => {
|
|
358
|
-
requireFfmpeg();
|
|
359
376
|
const jobDir = jobDirOf(o);
|
|
360
|
-
jobConfig(o, jobDir
|
|
377
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
361
378
|
const start = num(o.start);
|
|
362
379
|
const end = num(o.end);
|
|
363
380
|
if (start === void 0 || end === void 0 || end <= start) throw new Error("--start and --end must be numbers with end > start");
|
|
364
|
-
const
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (o.json) console.log(JSON.stringify({ ok: true, png:
|
|
381
|
+
const res = await client.qaWaveform({ start, end });
|
|
382
|
+
const dir = qaDir(jobDir);
|
|
383
|
+
const png = waveformPngPath(dir, start, end);
|
|
384
|
+
const wordsPath = waveformWordsPath(dir, start, end);
|
|
385
|
+
const dl = await client.download(res.url, png);
|
|
386
|
+
mkdirSync(dir, { recursive: true });
|
|
387
|
+
writeFileSync(wordsPath, JSON.stringify(res.words, null, 2) + "\n", "utf8");
|
|
388
|
+
if (o.json) console.log(JSON.stringify({ ok: true, png: dl.path, words_path: wordsPath, words: res.words.length }, null, 2));
|
|
372
389
|
else {
|
|
373
|
-
console.log(`waveform: ${
|
|
374
|
-
|
|
390
|
+
console.log(`waveform: ${dl.path}`);
|
|
391
|
+
console.log(`words: ${wordsPath} (${res.words.length} word(s))`);
|
|
375
392
|
}
|
|
376
393
|
});
|
|
377
394
|
});
|
|
@@ -465,7 +482,6 @@ addCloudOptions(
|
|
|
465
482
|
else console.log(`pushed: ${o.stage} (run ${runId})`);
|
|
466
483
|
return;
|
|
467
484
|
}
|
|
468
|
-
requireFfmpeg();
|
|
469
485
|
if (o.timeline) {
|
|
470
486
|
const res2 = await pushTimeline2(client, jobDir, runId);
|
|
471
487
|
if (!res2.ok) return printPush2Failure(res2, !!o.json);
|
|
@@ -508,46 +524,52 @@ addCloudOptions(
|
|
|
508
524
|
}
|
|
509
525
|
});
|
|
510
526
|
});
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const client = new CloudClient(jobConfig(o, jobDir));
|
|
520
|
-
const created = await client.createRender(mode);
|
|
521
|
-
if (!o.wait) {
|
|
522
|
-
if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: created.status }, null, 2));
|
|
523
|
-
else {
|
|
524
|
-
console.log(`render_id: ${created.renderId}`);
|
|
525
|
-
console.log(`status: ${created.status}`);
|
|
526
|
-
}
|
|
527
|
-
return;
|
|
528
|
-
}
|
|
529
|
-
const intervalMs = (num(o.pollInterval) ?? 10) * 1e3;
|
|
530
|
-
const state = await pollRender(client, created.renderId, {
|
|
531
|
-
intervalMs,
|
|
532
|
-
onTick: (s, elapsed) => {
|
|
533
|
-
if (!o.json) process.stderr.write(` [${Math.round(elapsed / 1e3)}s] ${s.status}
|
|
534
|
-
`);
|
|
535
|
-
}
|
|
536
|
-
});
|
|
537
|
-
if (state.status === "failed") {
|
|
538
|
-
const detail = state.error !== void 0 ? typeof state.error === "string" ? state.error : JSON.stringify(state.error) : "unknown error";
|
|
539
|
-
throw new Error(`render ${created.renderId} failed: ${detail}`);
|
|
540
|
-
}
|
|
541
|
-
if (!state.videoUrl) throw new Error(`render ${created.renderId} completed without a video_url`);
|
|
542
|
-
const out = o.out ? resolvePath(o.out) : defaultRenderOut(jobDir, mode);
|
|
543
|
-
const dl = await client.download(state.videoUrl, out);
|
|
544
|
-
if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: state.status, video_url: state.videoUrl, out: dl.path, bytes: dl.bytes }, null, 2));
|
|
527
|
+
async function runCloudRender(o, wait) {
|
|
528
|
+
const mode = o.mode;
|
|
529
|
+
if (mode !== "preview" && mode !== "final") throw new Error("--mode must be preview|final");
|
|
530
|
+
const jobDir = jobDirOf(o);
|
|
531
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
532
|
+
const created = await client.createRender(mode);
|
|
533
|
+
if (!wait) {
|
|
534
|
+
if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: created.status }, null, 2));
|
|
545
535
|
else {
|
|
546
536
|
console.log(`render_id: ${created.renderId}`);
|
|
547
|
-
console.log(`status: ${
|
|
548
|
-
|
|
537
|
+
console.log(`status: ${created.status}`);
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const intervalMs = (num(o.pollInterval) ?? 10) * 1e3;
|
|
542
|
+
const state = await pollRender(client, created.renderId, {
|
|
543
|
+
intervalMs,
|
|
544
|
+
onTick: (s, elapsed) => {
|
|
545
|
+
if (!o.json) process.stderr.write(` [${Math.round(elapsed / 1e3)}s] ${s.status}
|
|
546
|
+
`);
|
|
549
547
|
}
|
|
550
548
|
});
|
|
549
|
+
if (state.status === "failed") {
|
|
550
|
+
const detail = state.error !== void 0 ? typeof state.error === "string" ? state.error : JSON.stringify(state.error) : "unknown error";
|
|
551
|
+
throw new Error(`render ${created.renderId} failed: ${detail}`);
|
|
552
|
+
}
|
|
553
|
+
if (!state.videoUrl) throw new Error(`render ${created.renderId} completed without a video_url`);
|
|
554
|
+
const out = o.out ? resolvePath(String(o.out)) : defaultRenderOut(jobDir, mode);
|
|
555
|
+
const dl = await client.download(state.videoUrl, out);
|
|
556
|
+
if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: state.status, video_url: state.videoUrl, out: dl.path, bytes: dl.bytes }, null, 2));
|
|
557
|
+
else {
|
|
558
|
+
console.log(`render_id: ${created.renderId}`);
|
|
559
|
+
console.log(`status: ${state.status}`);
|
|
560
|
+
console.log(`out: ${dl.path} (${dl.bytes} bytes)`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
addCloudOptions(
|
|
564
|
+
program.command("render").description("Cloud-render the compiled plan (works right after `timeline compile`); waits and downloads by default").option("--mode <mode>", "preview|final", "preview").option("--job-dir <path>", "Job directory (download target; default: cwd)").option("--no-wait", "Submit only; do not poll/download").option("--poll-interval <s>", "Poll interval in seconds", "10").option("--out <path>", "Output path (default <job>/preview.mp4 or final.mp4)").option("--json", "Print JSON")
|
|
565
|
+
).action(async (o) => {
|
|
566
|
+
await withCloudErrors(!!o.json, () => runCloudRender(o, o.wait !== false));
|
|
567
|
+
});
|
|
568
|
+
var cloudCmd = program.command("cloud").description("Cloud render + status (clipready render API)");
|
|
569
|
+
addCloudOptions(
|
|
570
|
+
cloudCmd.command("render").description("Submit a render; with --wait, poll to completion and download the result").requiredOption("--mode <mode>", "preview|final").option("--job-dir <path>", "Job directory (download target; default: cwd)").option("--wait", "Poll until the render is terminal, then download").option("--poll-interval <s>", "Poll interval in seconds", "10").option("--out <path>", "Output path (default <job>/preview.mp4 or final.mp4)").option("--json", "Print JSON")
|
|
571
|
+
).action(async (o) => {
|
|
572
|
+
await withCloudErrors(!!o.json, () => runCloudRender(o, !!o.wait));
|
|
551
573
|
});
|
|
552
574
|
addCloudOptions(
|
|
553
575
|
cloudCmd.command("status").description("Fetch the current status of a render").argument("<render_id>", "Render id").option("--json", "Print JSON")
|
|
@@ -580,18 +602,17 @@ addCloudOptions(
|
|
|
580
602
|
});
|
|
581
603
|
});
|
|
582
604
|
var STATUS_FILES = [
|
|
583
|
-
"vertical_src.mp4",
|
|
584
605
|
"timeline.json",
|
|
585
606
|
"resolved/plan.json",
|
|
586
607
|
"resolved/diagnostics.json",
|
|
587
608
|
"edl.json",
|
|
588
609
|
"preview.mp4",
|
|
589
610
|
"final.mp4",
|
|
590
|
-
"preview.mp4",
|
|
591
611
|
"takes_packed.md",
|
|
592
612
|
"flags.txt",
|
|
593
613
|
"word_dump.txt",
|
|
594
614
|
"coverage.json",
|
|
615
|
+
"probe.json",
|
|
595
616
|
"verify.json",
|
|
596
617
|
"review/round.json",
|
|
597
618
|
"review/instruction.md"
|
|
@@ -622,6 +643,85 @@ addCloudOptions(
|
|
|
622
643
|
}
|
|
623
644
|
});
|
|
624
645
|
});
|
|
646
|
+
function promptSecret(promptText) {
|
|
647
|
+
return new Promise((resolve, reject) => {
|
|
648
|
+
const stdin = process.stdin;
|
|
649
|
+
process.stderr.write(promptText);
|
|
650
|
+
if (!stdin.isTTY) {
|
|
651
|
+
let data = "";
|
|
652
|
+
stdin.setEncoding("utf8");
|
|
653
|
+
stdin.on("data", (d) => data += d);
|
|
654
|
+
stdin.on("end", () => resolve(data.trim()));
|
|
655
|
+
stdin.resume();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
stdin.setRawMode(true);
|
|
659
|
+
stdin.resume();
|
|
660
|
+
stdin.setEncoding("utf8");
|
|
661
|
+
let value = "";
|
|
662
|
+
const done = (fn) => {
|
|
663
|
+
stdin.setRawMode(false);
|
|
664
|
+
stdin.pause();
|
|
665
|
+
stdin.off("data", onData);
|
|
666
|
+
process.stderr.write("\n");
|
|
667
|
+
fn();
|
|
668
|
+
};
|
|
669
|
+
const onData = (chunk) => {
|
|
670
|
+
for (const c of chunk) {
|
|
671
|
+
if (c === "\r" || c === "\n" || c === "") return done(() => resolve(value.trim()));
|
|
672
|
+
if (c === "") return done(() => reject(new Error("cancelled")));
|
|
673
|
+
if (c === "\x7F" || c === "\b") value = value.slice(0, -1);
|
|
674
|
+
else value += c;
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
stdin.on("data", onData);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
var authCmd = program.command("auth").description("Log in to ClipReady (stores the API key in the config file)");
|
|
681
|
+
authCmd.command("api-key").description("Paste your ClipReady API key once \u2014 verified against the cloud, then saved").argument("[key]", "API key (omit to be prompted; input is hidden)").option("--api-base <url>", "API base URL (default: the hosted ClipReady cloud)").action(async (keyArg, o) => {
|
|
682
|
+
const base = stripTrailingSlash(resolveApiBase(o.apiBase));
|
|
683
|
+
const key = (keyArg ?? await promptSecret(`Paste your ClipReady API key (from ${base}/settings): `)).trim();
|
|
684
|
+
if (!key) throw new Error("no API key provided");
|
|
685
|
+
let keyName = null;
|
|
686
|
+
try {
|
|
687
|
+
const me = await new CloudClient({ base, credential: key }).me();
|
|
688
|
+
const k = me.key;
|
|
689
|
+
keyName = typeof k?.name === "string" ? k.name : null;
|
|
690
|
+
} catch (err) {
|
|
691
|
+
throw new Error(
|
|
692
|
+
`that key was rejected by ${base} \u2014 check it at ${base}/settings and try again` + (err instanceof Error ? `
|
|
693
|
+
(${err.message})` : "")
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
savePublicConfigKey("CLIPREADY_API_KEY", key);
|
|
697
|
+
if (o.apiBase) savePublicConfigKey("CLIPREADY_API_BASE", base);
|
|
698
|
+
console.log(`Logged in${keyName ? ` with key "${keyName}"` : ""} (\u2026${key.slice(-4)}) \u2014 saved to ${publicConfigEnvPath()}`);
|
|
699
|
+
console.log(`Cloud: ${base}`);
|
|
700
|
+
});
|
|
701
|
+
authCmd.command("status").description("Show the active credential/base and verify them against the cloud").action(async () => {
|
|
702
|
+
loadPublicConfigEnv();
|
|
703
|
+
const base = stripTrailingSlash(resolveApiBase(void 0));
|
|
704
|
+
const key = resolveApiKey(void 0);
|
|
705
|
+
console.log(`config: ${publicConfigEnvPath()}`);
|
|
706
|
+
console.log(`cloud: ${base}`);
|
|
707
|
+
if (!key) {
|
|
708
|
+
console.log("key: (none) \u2014 run `video-editing auth api-key`");
|
|
709
|
+
process.exitCode = 1;
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
try {
|
|
713
|
+
const me = await new CloudClient({ base, credential: key }).me();
|
|
714
|
+
const k = me.key;
|
|
715
|
+
const keyName = typeof k?.name === "string" ? k.name : null;
|
|
716
|
+
console.log(`key: \u2026${key.slice(-4)}${keyName ? ` ("${keyName}")` : ""} \u2014 valid`);
|
|
717
|
+
} catch {
|
|
718
|
+
console.log(`key: \u2026${key.slice(-4)} \u2014 REJECTED by ${base}`);
|
|
719
|
+
process.exitCode = 1;
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
authCmd.command("logout").description("Remove the stored API key").action(() => {
|
|
723
|
+
console.log(`removed key from ${removePublicConfigKey("CLIPREADY_API_KEY")}`);
|
|
724
|
+
});
|
|
625
725
|
program.command("config").description("Manage CLI config (clipready API key/base)").argument("<action>", "set-key | set-base | path").argument("[value]", "value for set-key / set-base").action((action, value) => {
|
|
626
726
|
if (action === "path") {
|
|
627
727
|
console.log(publicConfigEnvPath());
|