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
package/dist/cli.js
CHANGED
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
import {
|
|
3
3
|
CloudClient,
|
|
4
4
|
REVIEW_STAGES2,
|
|
5
|
+
assertRunCompleted,
|
|
5
6
|
compileRemote,
|
|
6
|
-
composeCloud,
|
|
7
7
|
defaultRenderOut,
|
|
8
|
+
formatRunEvent,
|
|
9
|
+
framesPngPath,
|
|
8
10
|
initCloudJob,
|
|
11
|
+
inspectPngPath,
|
|
9
12
|
installSkill,
|
|
10
13
|
instructionPath2,
|
|
11
14
|
jobJsonPath,
|
|
15
|
+
joinSourceWindows,
|
|
16
|
+
joinSpanWindow,
|
|
12
17
|
loadPublicConfigEnv,
|
|
13
18
|
loadTimeline2,
|
|
19
|
+
outWindowToSourceWindows,
|
|
14
20
|
pollRender,
|
|
21
|
+
pollRun,
|
|
15
22
|
publicConfigEnvPath,
|
|
16
23
|
pullFiles,
|
|
17
24
|
pullReview2,
|
|
@@ -19,24 +26,74 @@ import {
|
|
|
19
26
|
pushFiles,
|
|
20
27
|
pushStage2,
|
|
21
28
|
pushTimeline2,
|
|
22
|
-
|
|
23
|
-
qaWaveformLocal,
|
|
29
|
+
qaDir,
|
|
24
30
|
readJobFile,
|
|
25
|
-
|
|
31
|
+
readPlanSegments,
|
|
32
|
+
removePublicConfigKey,
|
|
33
|
+
resolveApiBase,
|
|
34
|
+
resolveApiKey,
|
|
26
35
|
resolveConfig,
|
|
27
36
|
resolveRunId2,
|
|
28
37
|
savePublicConfigKey,
|
|
38
|
+
stripTrailingSlash,
|
|
29
39
|
timelineJsonPath2,
|
|
30
40
|
transcribeCloud,
|
|
31
41
|
validateLocal,
|
|
32
42
|
verifyCloud,
|
|
33
|
-
videoIdFromJob
|
|
34
|
-
|
|
43
|
+
videoIdFromJob,
|
|
44
|
+
waveformPngPath,
|
|
45
|
+
waveformWordsPath,
|
|
46
|
+
webVideoUrl
|
|
47
|
+
} from "./chunk-2OM7WV4W.js";
|
|
35
48
|
|
|
36
49
|
// src/public/cli.ts
|
|
37
|
-
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
38
|
-
import { join, resolve as resolvePath } from "path";
|
|
50
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
51
|
+
import { join as join2, resolve as resolvePath } from "path";
|
|
39
52
|
import { Command } from "commander";
|
|
53
|
+
|
|
54
|
+
// src/cloud/smooth-joins.ts
|
|
55
|
+
import { existsSync, readFileSync } from "fs";
|
|
56
|
+
import { join } from "path";
|
|
57
|
+
var JOINED_REPORT_REL = "audio/joined.json";
|
|
58
|
+
async function smoothJoinsCloud(client, jobDir, opts = {}) {
|
|
59
|
+
const verbose = opts.verbose !== false;
|
|
60
|
+
const log = (line) => {
|
|
61
|
+
if (verbose) process.stderr.write(line + "\n");
|
|
62
|
+
};
|
|
63
|
+
const { runId } = await client.smoothJoinsStart({
|
|
64
|
+
...opts.crossfadeMs !== void 0 ? { crossfade_ms: opts.crossfadeMs } : {},
|
|
65
|
+
...opts.curve !== void 0 ? { curve: opts.curve } : {},
|
|
66
|
+
...opts.maxMs !== void 0 ? { max_ms: opts.maxMs } : {},
|
|
67
|
+
...opts.overrides !== void 0 && opts.overrides.length > 0 ? { joins: opts.overrides.map((o) => ({ index: o.index, crossfade_ms: o.crossfadeMs })) } : {}
|
|
68
|
+
});
|
|
69
|
+
log(` smooth-joins run: ${runId}`);
|
|
70
|
+
const state = await pollRun(client, runId, {
|
|
71
|
+
intervalMs: opts.pollIntervalMs,
|
|
72
|
+
onEvent: (ev) => log(` ${formatRunEvent(ev)}`)
|
|
73
|
+
});
|
|
74
|
+
assertRunCompleted(state, "smooth-joins");
|
|
75
|
+
await pullFiles(client, jobDir, { only: [JOINED_REPORT_REL] });
|
|
76
|
+
const reportPath = join(jobDir, JOINED_REPORT_REL);
|
|
77
|
+
let joins = [];
|
|
78
|
+
let totalDurationS = null;
|
|
79
|
+
if (existsSync(reportPath)) {
|
|
80
|
+
try {
|
|
81
|
+
const r = JSON.parse(readFileSync(reportPath, "utf8"));
|
|
82
|
+
if (Array.isArray(r.joins)) joins = r.joins;
|
|
83
|
+
if (typeof r.total_duration_s === "number") totalDurationS = r.total_duration_s;
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
runId,
|
|
89
|
+
reportPath,
|
|
90
|
+
joins,
|
|
91
|
+
clamped: joins.filter((j) => j.clamped).length,
|
|
92
|
+
totalDurationS
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/public/cli.ts
|
|
40
97
|
loadPublicConfigEnv();
|
|
41
98
|
function num(v) {
|
|
42
99
|
if (v === void 0) return void 0;
|
|
@@ -77,17 +134,16 @@ function jobDirOf(o) {
|
|
|
77
134
|
}
|
|
78
135
|
var pkg;
|
|
79
136
|
try {
|
|
80
|
-
pkg = JSON.parse(
|
|
137
|
+
pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
|
|
81
138
|
} catch {
|
|
82
|
-
pkg = { version: "
|
|
139
|
+
pkg = { version: "3.0.0" };
|
|
83
140
|
}
|
|
84
141
|
var program = new Command();
|
|
85
|
-
program.name("video-editing").description("
|
|
142
|
+
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
143
|
addCloudOptions(
|
|
87
|
-
program.command("init").description("Create
|
|
144
|
+
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
145
|
).action(async (o) => {
|
|
89
146
|
await withCloudErrors(!!o.json, async () => {
|
|
90
|
-
requireFfmpeg();
|
|
91
147
|
const cfg = resolveConfig(cloudFlags(o), { requireVideoId: false });
|
|
92
148
|
const client = new CloudClient(cfg);
|
|
93
149
|
let res;
|
|
@@ -96,7 +152,7 @@ addCloudOptions(
|
|
|
96
152
|
source: o.source,
|
|
97
153
|
jobDir: o.jobDir,
|
|
98
154
|
title: o.title,
|
|
99
|
-
|
|
155
|
+
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
100
156
|
verbose: !o.json
|
|
101
157
|
});
|
|
102
158
|
} catch (err) {
|
|
@@ -107,43 +163,35 @@ Your credential was rejected \u2014 get your API key at ${cfg.base}/settings and
|
|
|
107
163
|
throw err;
|
|
108
164
|
}
|
|
109
165
|
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));
|
|
166
|
+
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
167
|
} else {
|
|
112
168
|
console.log(`job: ${res.jobDir}`);
|
|
113
169
|
console.log(`video_id: ${res.videoId}`);
|
|
114
170
|
console.log(`project_id: ${res.projectId}`);
|
|
115
171
|
console.log(`job.json: ${res.jobJson}`);
|
|
172
|
+
for (const p of res.pulled.pulled) console.log(` ${p.path} \u2192 ${p.localPath}${p.skipped ? " (unchanged)" : ""}`);
|
|
173
|
+
if (res.pulled.missing.length > 0) console.log(` missing on server: ${res.pulled.missing.join(", ")}`);
|
|
174
|
+
console.log(`web: ${res.webUrl}`);
|
|
116
175
|
}
|
|
117
176
|
});
|
|
118
177
|
});
|
|
119
178
|
addCloudOptions(
|
|
120
|
-
program.command("transcribe").description("
|
|
179
|
+
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
180
|
).action(async (o) => {
|
|
122
181
|
await withCloudErrors(!!o.json, async () => {
|
|
123
|
-
requireFfmpeg();
|
|
124
182
|
const jobDir = jobDirOf(o);
|
|
125
183
|
const cfg = jobConfig(o, jobDir);
|
|
126
184
|
const client = new CloudClient(cfg);
|
|
127
185
|
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
186
|
const res = await transcribeCloud(client, jobDir, {
|
|
136
|
-
media,
|
|
137
187
|
stem: job?.stem ?? "source",
|
|
138
|
-
language: o.language,
|
|
139
|
-
numSpeakers: num(o.numSpeakers),
|
|
140
188
|
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
141
189
|
verbose: !o.json
|
|
142
190
|
});
|
|
143
191
|
if (o.json) {
|
|
144
|
-
console.log(JSON.stringify({ ok: true, run_id: res.runId,
|
|
192
|
+
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
193
|
} else {
|
|
146
|
-
console.log(`run: ${res.runId}`);
|
|
194
|
+
if (res.runId) console.log(`run: ${res.runId}`);
|
|
147
195
|
for (const p of res.pulled.pulled) console.log(` ${p.path} \u2192 ${p.localPath}${p.skipped ? " (unchanged)" : ""}`);
|
|
148
196
|
if (res.pulled.missing.length > 0) console.log(` missing on server: ${res.pulled.missing.join(", ")}`);
|
|
149
197
|
}
|
|
@@ -214,7 +262,6 @@ addCloudOptions(
|
|
|
214
262
|
});
|
|
215
263
|
async function runRemoteCompile(o) {
|
|
216
264
|
const jobDir = jobDirOf(o);
|
|
217
|
-
requireFfmpeg();
|
|
218
265
|
const client = new CloudClient(jobConfig(o, jobDir));
|
|
219
266
|
const loaded = loadTimeline2(jobDir);
|
|
220
267
|
if (!loaded.doc) {
|
|
@@ -230,8 +277,10 @@ async function runRemoteCompile(o) {
|
|
|
230
277
|
}
|
|
231
278
|
const res = await compileRemote(client, jobDir, loaded);
|
|
232
279
|
const diagnostics = res.response.diagnostics;
|
|
280
|
+
const job = readJobFile(jobDir);
|
|
281
|
+
const webUrl = res.ok && job && job.project_id !== "" ? webVideoUrl(client.cfg.base, job.project_id, job.video_id) : null;
|
|
233
282
|
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));
|
|
283
|
+
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
284
|
} else {
|
|
236
285
|
if (res.ok && res.planPath) {
|
|
237
286
|
console.log(`plan: ${res.planPath}`);
|
|
@@ -243,6 +292,7 @@ async function runRemoteCompile(o) {
|
|
|
243
292
|
const d = e;
|
|
244
293
|
console.log(` [error] ${d.path ?? ""}: ${d.message ?? ""}`);
|
|
245
294
|
}
|
|
295
|
+
if (webUrl) console.log(`View & comment: ${webUrl}`);
|
|
246
296
|
}
|
|
247
297
|
if (!res.ok) {
|
|
248
298
|
const ambiguous = (diagnostics?.errors ?? []).some((e) => e.code === "anchor-ambiguous");
|
|
@@ -261,8 +311,8 @@ addCloudOptions(
|
|
|
261
311
|
await withCloudErrors(!!o.json, async () => {
|
|
262
312
|
const jobDir = jobDirOf(o);
|
|
263
313
|
const tlPath = timelineJsonPath2(jobDir);
|
|
264
|
-
if (!
|
|
265
|
-
const raw = JSON.parse(
|
|
314
|
+
if (!existsSync2(tlPath)) throw new Error(`timeline not found: ${tlPath}`);
|
|
315
|
+
const raw = JSON.parse(readFileSync2(tlPath, "utf8"));
|
|
266
316
|
const captions = raw.captions !== null && typeof raw.captions === "object" ? raw.captions : {};
|
|
267
317
|
captions.enabled = true;
|
|
268
318
|
captions.preset = o.preset;
|
|
@@ -273,10 +323,9 @@ addCloudOptions(
|
|
|
273
323
|
});
|
|
274
324
|
});
|
|
275
325
|
addCloudOptions(
|
|
276
|
-
program.command("verify").description("Content-verify the rendered preview server-side
|
|
326
|
+
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
327
|
).action(async (o) => {
|
|
278
328
|
await withCloudErrors(!!o.json, async () => {
|
|
279
|
-
requireFfmpeg();
|
|
280
329
|
const jobDir = jobDirOf(o);
|
|
281
330
|
const client = new CloudClient(jobConfig(o, jobDir));
|
|
282
331
|
const res = await verifyCloud(client, jobDir, {
|
|
@@ -294,84 +343,153 @@ addCloudOptions(
|
|
|
294
343
|
if (res.verdict === "needs-fixes") process.exitCode = 2;
|
|
295
344
|
});
|
|
296
345
|
});
|
|
346
|
+
function collectJoinOverride(raw, acc = []) {
|
|
347
|
+
const m = raw.match(/^(\d+):(\d+(?:\.\d+)?)$/);
|
|
348
|
+
if (!m) {
|
|
349
|
+
throw new Error(`--join expects <index>:<ms>, e.g. --join 3:40 (got "${raw}")`);
|
|
350
|
+
}
|
|
351
|
+
acc.push({ index: Number(m[1]), crossfadeMs: Number(m[2]) });
|
|
352
|
+
return acc;
|
|
353
|
+
}
|
|
297
354
|
addCloudOptions(
|
|
298
|
-
program.command("
|
|
355
|
+
program.command("smooth-joins").description(
|
|
356
|
+
"Apply an equal-power audio crossfade to EVERY join (Premiere's Constant Power transition). Optional \u2014 renders are unaffected unless you run it"
|
|
357
|
+
).option("--job-dir <path>", "Job directory (default: cwd)").option("--crossfade-ms <ms>", "Transition length at every join (default: 130 \u2014 Premiere's 4 frames); 0 disables").option("--curve <name>", "equal-power (default) or linear").option("--max-ms <ms>", "Ceiling on any single join").option(
|
|
358
|
+
"--join <index:ms>",
|
|
359
|
+
"Per-join override, repeatable (e.g. --join 3:40 --join 7:220)",
|
|
360
|
+
collectJoinOverride
|
|
361
|
+
).option("--poll-interval <s>", "Run poll interval in seconds", "5").option("--json", "Print JSON")
|
|
299
362
|
).action(async (o) => {
|
|
300
363
|
await withCloudErrors(!!o.json, async () => {
|
|
301
|
-
|
|
364
|
+
if (o.curve !== void 0 && o.curve !== "equal-power" && o.curve !== "linear") {
|
|
365
|
+
throw new Error(`--curve must be "equal-power" or "linear" (got "${o.curve}")`);
|
|
366
|
+
}
|
|
302
367
|
const jobDir = jobDirOf(o);
|
|
303
368
|
const client = new CloudClient(jobConfig(o, jobDir));
|
|
304
|
-
const res = await
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
369
|
+
const res = await smoothJoinsCloud(client, jobDir, {
|
|
370
|
+
crossfadeMs: num(o.crossfadeMs),
|
|
371
|
+
...o.curve !== void 0 ? { curve: o.curve } : {},
|
|
372
|
+
maxMs: num(o.maxMs),
|
|
373
|
+
...Array.isArray(o.join) ? { overrides: o.join } : {},
|
|
374
|
+
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
310
375
|
verbose: !o.json
|
|
311
376
|
});
|
|
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;
|
|
317
|
-
}
|
|
318
377
|
if (o.json) {
|
|
319
|
-
console.log(
|
|
378
|
+
console.log(
|
|
379
|
+
JSON.stringify(
|
|
380
|
+
{
|
|
381
|
+
ok: true,
|
|
382
|
+
run_id: res.runId,
|
|
383
|
+
report: res.reportPath,
|
|
384
|
+
total_duration_s: res.totalDurationS,
|
|
385
|
+
joins: res.joins
|
|
386
|
+
},
|
|
387
|
+
null,
|
|
388
|
+
2
|
|
389
|
+
)
|
|
390
|
+
);
|
|
320
391
|
} else {
|
|
321
|
-
console.log(`
|
|
322
|
-
|
|
323
|
-
|
|
392
|
+
console.log(`smooth-joins: ${res.joins.length} join(s), ${res.clamped} clamped by available handle`);
|
|
393
|
+
for (const j of res.joins.filter((x) => x.clamped)) {
|
|
394
|
+
console.log(` join ${j.index}: ${j.applied_ms}ms (asked ${j.requested_ms}ms) \u2014 ${j.reason ?? "clamped"}`);
|
|
395
|
+
}
|
|
396
|
+
console.log(`report: ${res.reportPath}`);
|
|
324
397
|
}
|
|
325
398
|
});
|
|
326
399
|
});
|
|
327
|
-
var qaCmd = program.command("qa").description("
|
|
328
|
-
function
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
400
|
+
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");
|
|
401
|
+
function framesWindows(jobDir, o, joinMode = "split") {
|
|
402
|
+
const start = num(o.start);
|
|
403
|
+
const end = num(o.end);
|
|
404
|
+
const joinIdx = num(o.join);
|
|
405
|
+
const outStart = num(o.outStart);
|
|
406
|
+
const outEnd = num(o.outEnd);
|
|
407
|
+
const modes = [start !== void 0 || end !== void 0, joinIdx !== void 0, outStart !== void 0 || outEnd !== void 0];
|
|
408
|
+
if (modes.filter(Boolean).length !== 1) {
|
|
409
|
+
throw new Error("pass exactly one addressing mode: --start/--end (source s), --join <i>, or --out-start/--out-end (output s)");
|
|
410
|
+
}
|
|
411
|
+
if (joinIdx !== void 0) {
|
|
412
|
+
const segs = readPlanSegments(jobDir);
|
|
413
|
+
const span = num(o.span) ?? 2;
|
|
414
|
+
return joinMode === "span" ? [joinSpanWindow(segs, joinIdx, span)] : joinSourceWindows(segs, joinIdx, span);
|
|
415
|
+
}
|
|
416
|
+
if (outStart !== void 0 || outEnd !== void 0) {
|
|
417
|
+
if (outStart === void 0 || outEnd === void 0) throw new Error("--out-start and --out-end are both required");
|
|
418
|
+
return outWindowToSourceWindows(readPlanSegments(jobDir), outStart, outEnd);
|
|
419
|
+
}
|
|
420
|
+
if (start === void 0 || end === void 0 || end <= start) throw new Error("--start and --end must be numbers with end > start");
|
|
421
|
+
return [{ start, end, suffix: "" }];
|
|
332
422
|
}
|
|
333
423
|
addCloudOptions(
|
|
334
|
-
qaCmd.command("frames").description("
|
|
424
|
+
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")
|
|
335
425
|
).action(async (o) => {
|
|
336
426
|
await withCloudErrors(!!o.json, async () => {
|
|
337
|
-
requireFfmpeg();
|
|
338
427
|
const jobDir = jobDirOf(o);
|
|
339
|
-
jobConfig(o, jobDir
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
start,
|
|
346
|
-
|
|
347
|
-
frames:
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
else
|
|
428
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
429
|
+
const windows = framesWindows(jobDir, o);
|
|
430
|
+
const dir = qaDir(jobDir);
|
|
431
|
+
const outputs = [];
|
|
432
|
+
for (const w of windows) {
|
|
433
|
+
const res = await client.qaFrames({ start: w.start, end: w.end, cols: num(o.cols), interval: num(o.interval) });
|
|
434
|
+
const png = framesPngPath(dir, w.start, w.end, w.suffix);
|
|
435
|
+
const dl = await client.download(res.url, png);
|
|
436
|
+
outputs.push({ png: dl.path, width: res.width, height: res.height, frames: res.frames, tStart: res.tStart, tEnd: res.tEnd });
|
|
437
|
+
}
|
|
438
|
+
if (o.json) {
|
|
439
|
+
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));
|
|
440
|
+
} else {
|
|
441
|
+
for (const r of outputs) {
|
|
442
|
+
console.log(`frames: ${r.png}`);
|
|
443
|
+
console.log(`grid: ${r.frames} frame(s), ${r.width}x${r.height}, t ${r.tStart}s\u2013${r.tEnd}s`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
352
446
|
});
|
|
353
447
|
});
|
|
354
448
|
addCloudOptions(
|
|
355
|
-
qaCmd.command("
|
|
449
|
+
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")
|
|
356
450
|
).action(async (o) => {
|
|
357
451
|
await withCloudErrors(!!o.json, async () => {
|
|
358
|
-
requireFfmpeg();
|
|
359
452
|
const jobDir = jobDirOf(o);
|
|
360
|
-
jobConfig(o, jobDir
|
|
453
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
454
|
+
const windows = framesWindows(jobDir, o, "span");
|
|
455
|
+
const dir = qaDir(jobDir);
|
|
456
|
+
const outputs = [];
|
|
457
|
+
for (const w of windows) {
|
|
458
|
+
const res = await client.qaInspect({ start: w.start, end: w.end, nFrames: num(o.nFrames) });
|
|
459
|
+
const png = inspectPngPath(dir, w.start, w.end, w.suffix);
|
|
460
|
+
const dl = await client.download(res.url, png);
|
|
461
|
+
outputs.push({ png: dl.path, tStart: res.tStart, tEnd: res.tEnd, cuts: res.cuts, cached: res.cached });
|
|
462
|
+
}
|
|
463
|
+
if (o.json) {
|
|
464
|
+
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));
|
|
465
|
+
} else {
|
|
466
|
+
for (const r of outputs) {
|
|
467
|
+
console.log(`inspect: ${r.png}`);
|
|
468
|
+
console.log(`window: t ${r.tStart}s\u2013${r.tEnd}s, ${r.cuts.length} cut(s) shaded${r.cached ? " (cached)" : ""}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
addCloudOptions(
|
|
474
|
+
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")
|
|
475
|
+
).action(async (o) => {
|
|
476
|
+
await withCloudErrors(!!o.json, async () => {
|
|
477
|
+
const jobDir = jobDirOf(o);
|
|
478
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
361
479
|
const start = num(o.start);
|
|
362
480
|
const end = num(o.end);
|
|
363
481
|
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:
|
|
482
|
+
const res = await client.qaWaveform({ start, end });
|
|
483
|
+
const dir = qaDir(jobDir);
|
|
484
|
+
const png = waveformPngPath(dir, start, end);
|
|
485
|
+
const wordsPath = waveformWordsPath(dir, start, end);
|
|
486
|
+
const dl = await client.download(res.url, png);
|
|
487
|
+
mkdirSync(dir, { recursive: true });
|
|
488
|
+
writeFileSync(wordsPath, JSON.stringify(res.words, null, 2) + "\n", "utf8");
|
|
489
|
+
if (o.json) console.log(JSON.stringify({ ok: true, png: dl.path, words_path: wordsPath, words: res.words.length }, null, 2));
|
|
372
490
|
else {
|
|
373
|
-
console.log(`waveform: ${
|
|
374
|
-
|
|
491
|
+
console.log(`waveform: ${dl.path}`);
|
|
492
|
+
console.log(`words: ${wordsPath} (${res.words.length} word(s))`);
|
|
375
493
|
}
|
|
376
494
|
});
|
|
377
495
|
});
|
|
@@ -440,7 +558,7 @@ addCloudOptions(
|
|
|
440
558
|
} else {
|
|
441
559
|
console.log("no active review round");
|
|
442
560
|
}
|
|
443
|
-
console.log(`timeline.json: ${res.wroteTimeline ? "written" :
|
|
561
|
+
console.log(`timeline.json: ${res.wroteTimeline ? "written" : existsSync2(timelineJsonPath2(jobDir)) ? "kept (already present locally)" : "not provided"}`);
|
|
444
562
|
if (res.downloadedSource) console.log(`source: ${res.downloadedSource}`);
|
|
445
563
|
}
|
|
446
564
|
});
|
|
@@ -465,7 +583,6 @@ addCloudOptions(
|
|
|
465
583
|
else console.log(`pushed: ${o.stage} (run ${runId})`);
|
|
466
584
|
return;
|
|
467
585
|
}
|
|
468
|
-
requireFfmpeg();
|
|
469
586
|
if (o.timeline) {
|
|
470
587
|
const res2 = await pushTimeline2(client, jobDir, runId);
|
|
471
588
|
if (!res2.ok) return printPush2Failure(res2, !!o.json);
|
|
@@ -500,54 +617,60 @@ addCloudOptions(
|
|
|
500
617
|
const jobDir = jobDirOf(o);
|
|
501
618
|
jobConfig(o, jobDir, { requireVideoId: false });
|
|
502
619
|
const path = instructionPath2(jobDir);
|
|
503
|
-
if (!
|
|
504
|
-
if (o.json) console.log(JSON.stringify({ ok: true, instruction: path, content:
|
|
620
|
+
if (!existsSync2(path)) throw new Error(`no instruction found at ${path} \u2014 run \`video-editing review pull\` first`);
|
|
621
|
+
if (o.json) console.log(JSON.stringify({ ok: true, instruction: path, content: readFileSync2(path, "utf8") }, null, 2));
|
|
505
622
|
else {
|
|
506
623
|
console.log(`instruction: ${path}`);
|
|
507
|
-
process.stdout.write(
|
|
624
|
+
process.stdout.write(readFileSync2(path, "utf8"));
|
|
508
625
|
}
|
|
509
626
|
});
|
|
510
627
|
});
|
|
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));
|
|
628
|
+
async function runCloudRender(o, wait) {
|
|
629
|
+
const mode = o.mode;
|
|
630
|
+
if (mode !== "preview" && mode !== "final") throw new Error("--mode must be preview|final");
|
|
631
|
+
const jobDir = jobDirOf(o);
|
|
632
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
633
|
+
const created = await client.createRender(mode);
|
|
634
|
+
if (!wait) {
|
|
635
|
+
if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: created.status }, null, 2));
|
|
545
636
|
else {
|
|
546
637
|
console.log(`render_id: ${created.renderId}`);
|
|
547
|
-
console.log(`status: ${
|
|
548
|
-
|
|
638
|
+
console.log(`status: ${created.status}`);
|
|
639
|
+
}
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const intervalMs = (num(o.pollInterval) ?? 10) * 1e3;
|
|
643
|
+
const state = await pollRender(client, created.renderId, {
|
|
644
|
+
intervalMs,
|
|
645
|
+
onTick: (s, elapsed) => {
|
|
646
|
+
if (!o.json) process.stderr.write(` [${Math.round(elapsed / 1e3)}s] ${s.status}
|
|
647
|
+
`);
|
|
549
648
|
}
|
|
550
649
|
});
|
|
650
|
+
if (state.status === "failed") {
|
|
651
|
+
const detail = state.error !== void 0 ? typeof state.error === "string" ? state.error : JSON.stringify(state.error) : "unknown error";
|
|
652
|
+
throw new Error(`render ${created.renderId} failed: ${detail}`);
|
|
653
|
+
}
|
|
654
|
+
if (!state.videoUrl) throw new Error(`render ${created.renderId} completed without a video_url`);
|
|
655
|
+
const out = o.out ? resolvePath(String(o.out)) : defaultRenderOut(jobDir, mode);
|
|
656
|
+
const dl = await client.download(state.videoUrl, out);
|
|
657
|
+
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));
|
|
658
|
+
else {
|
|
659
|
+
console.log(`render_id: ${created.renderId}`);
|
|
660
|
+
console.log(`status: ${state.status}`);
|
|
661
|
+
console.log(`out: ${dl.path} (${dl.bytes} bytes)`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
addCloudOptions(
|
|
665
|
+
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")
|
|
666
|
+
).action(async (o) => {
|
|
667
|
+
await withCloudErrors(!!o.json, () => runCloudRender(o, o.wait !== false));
|
|
668
|
+
});
|
|
669
|
+
var cloudCmd = program.command("cloud").description("Cloud render + status (clipready render API)");
|
|
670
|
+
addCloudOptions(
|
|
671
|
+
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")
|
|
672
|
+
).action(async (o) => {
|
|
673
|
+
await withCloudErrors(!!o.json, () => runCloudRender(o, !!o.wait));
|
|
551
674
|
});
|
|
552
675
|
addCloudOptions(
|
|
553
676
|
cloudCmd.command("status").description("Fetch the current status of a render").argument("<render_id>", "Render id").option("--json", "Print JSON")
|
|
@@ -580,18 +703,17 @@ addCloudOptions(
|
|
|
580
703
|
});
|
|
581
704
|
});
|
|
582
705
|
var STATUS_FILES = [
|
|
583
|
-
"vertical_src.mp4",
|
|
584
706
|
"timeline.json",
|
|
585
707
|
"resolved/plan.json",
|
|
586
708
|
"resolved/diagnostics.json",
|
|
587
709
|
"edl.json",
|
|
588
710
|
"preview.mp4",
|
|
589
711
|
"final.mp4",
|
|
590
|
-
"preview.mp4",
|
|
591
712
|
"takes_packed.md",
|
|
592
713
|
"flags.txt",
|
|
593
714
|
"word_dump.txt",
|
|
594
715
|
"coverage.json",
|
|
716
|
+
"probe.json",
|
|
595
717
|
"verify.json",
|
|
596
718
|
"review/round.json",
|
|
597
719
|
"review/instruction.md"
|
|
@@ -604,8 +726,8 @@ addCloudOptions(
|
|
|
604
726
|
jobConfig(o, jobDir, { requireVideoId: false });
|
|
605
727
|
const job = readJobFile(jobDir);
|
|
606
728
|
const artifacts = {};
|
|
607
|
-
if (job) artifacts[`transcripts/${job.stem}.json`] =
|
|
608
|
-
for (const f of STATUS_FILES) artifacts[f] =
|
|
729
|
+
if (job) artifacts[`transcripts/${job.stem}.json`] = existsSync2(join2(jobDir, "transcripts", `${job.stem}.json`));
|
|
730
|
+
for (const f of STATUS_FILES) artifacts[f] = existsSync2(join2(jobDir, f));
|
|
609
731
|
if (o.json) {
|
|
610
732
|
console.log(JSON.stringify({ ok: true, job_dir: jobDir, job: job ?? null, artifacts }, null, 2));
|
|
611
733
|
} else {
|
|
@@ -622,6 +744,85 @@ addCloudOptions(
|
|
|
622
744
|
}
|
|
623
745
|
});
|
|
624
746
|
});
|
|
747
|
+
function promptSecret(promptText) {
|
|
748
|
+
return new Promise((resolve, reject) => {
|
|
749
|
+
const stdin = process.stdin;
|
|
750
|
+
process.stderr.write(promptText);
|
|
751
|
+
if (!stdin.isTTY) {
|
|
752
|
+
let data = "";
|
|
753
|
+
stdin.setEncoding("utf8");
|
|
754
|
+
stdin.on("data", (d) => data += d);
|
|
755
|
+
stdin.on("end", () => resolve(data.trim()));
|
|
756
|
+
stdin.resume();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
stdin.setRawMode(true);
|
|
760
|
+
stdin.resume();
|
|
761
|
+
stdin.setEncoding("utf8");
|
|
762
|
+
let value = "";
|
|
763
|
+
const done = (fn) => {
|
|
764
|
+
stdin.setRawMode(false);
|
|
765
|
+
stdin.pause();
|
|
766
|
+
stdin.off("data", onData);
|
|
767
|
+
process.stderr.write("\n");
|
|
768
|
+
fn();
|
|
769
|
+
};
|
|
770
|
+
const onData = (chunk) => {
|
|
771
|
+
for (const c of chunk) {
|
|
772
|
+
if (c === "\r" || c === "\n" || c === "") return done(() => resolve(value.trim()));
|
|
773
|
+
if (c === "") return done(() => reject(new Error("cancelled")));
|
|
774
|
+
if (c === "\x7F" || c === "\b") value = value.slice(0, -1);
|
|
775
|
+
else value += c;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
stdin.on("data", onData);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
var authCmd = program.command("auth").description("Log in to ClipReady (stores the API key in the config file)");
|
|
782
|
+
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) => {
|
|
783
|
+
const base = stripTrailingSlash(resolveApiBase(o.apiBase));
|
|
784
|
+
const key = (keyArg ?? await promptSecret(`Paste your ClipReady API key (from ${base}/settings): `)).trim();
|
|
785
|
+
if (!key) throw new Error("no API key provided");
|
|
786
|
+
let keyName = null;
|
|
787
|
+
try {
|
|
788
|
+
const me = await new CloudClient({ base, credential: key }).me();
|
|
789
|
+
const k = me.key;
|
|
790
|
+
keyName = typeof k?.name === "string" ? k.name : null;
|
|
791
|
+
} catch (err) {
|
|
792
|
+
throw new Error(
|
|
793
|
+
`that key was rejected by ${base} \u2014 check it at ${base}/settings and try again` + (err instanceof Error ? `
|
|
794
|
+
(${err.message})` : "")
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
savePublicConfigKey("CLIPREADY_API_KEY", key);
|
|
798
|
+
if (o.apiBase) savePublicConfigKey("CLIPREADY_API_BASE", base);
|
|
799
|
+
console.log(`Logged in${keyName ? ` with key "${keyName}"` : ""} (\u2026${key.slice(-4)}) \u2014 saved to ${publicConfigEnvPath()}`);
|
|
800
|
+
console.log(`Cloud: ${base}`);
|
|
801
|
+
});
|
|
802
|
+
authCmd.command("status").description("Show the active credential/base and verify them against the cloud").action(async () => {
|
|
803
|
+
loadPublicConfigEnv();
|
|
804
|
+
const base = stripTrailingSlash(resolveApiBase(void 0));
|
|
805
|
+
const key = resolveApiKey(void 0);
|
|
806
|
+
console.log(`config: ${publicConfigEnvPath()}`);
|
|
807
|
+
console.log(`cloud: ${base}`);
|
|
808
|
+
if (!key) {
|
|
809
|
+
console.log("key: (none) \u2014 run `video-editing auth api-key`");
|
|
810
|
+
process.exitCode = 1;
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const me = await new CloudClient({ base, credential: key }).me();
|
|
815
|
+
const k = me.key;
|
|
816
|
+
const keyName = typeof k?.name === "string" ? k.name : null;
|
|
817
|
+
console.log(`key: \u2026${key.slice(-4)}${keyName ? ` ("${keyName}")` : ""} \u2014 valid`);
|
|
818
|
+
} catch {
|
|
819
|
+
console.log(`key: \u2026${key.slice(-4)} \u2014 REJECTED by ${base}`);
|
|
820
|
+
process.exitCode = 1;
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
authCmd.command("logout").description("Remove the stored API key").action(() => {
|
|
824
|
+
console.log(`removed key from ${removePublicConfigKey("CLIPREADY_API_KEY")}`);
|
|
825
|
+
});
|
|
625
826
|
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
827
|
if (action === "path") {
|
|
627
828
|
console.log(publicConfigEnvPath());
|