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/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
- qaFramesLocal,
23
- qaWaveformLocal,
29
+ qaDir,
24
30
  readJobFile,
25
- requireFfmpeg,
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
- } from "./chunk-SSXAUKGV.js";
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(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
137
+ pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
81
138
  } catch {
82
- pkg = { version: "2.0.0" };
139
+ pkg = { version: "3.0.0" };
83
140
  }
84
141
  var program = new Command();
85
- program.name("video-editing").description("Cloud CLI for editing talking-head reels with clipready: init, transcribe, timeline authoring, verify, compose, QA, file sync, review rounds, cloud renders.").version(pkg.version);
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 a cloud job: probe the source, optionally bake the vertical master, upload, and write <job>/job.json").requiredOption("--source <path>", "Source video path").option("--job-dir <path>", "Job directory (default ./job_<stem>)").option("--title <title>", "Project title (default: source stem)").option("--master", "Bake the vertical 9:16 master locally and upload it as the source").option("--json", "Print JSON")
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
- master: !!o.master,
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("Extract + upload the WAV locally, run server-side Scribe transcription, pull the artifact set").option("--job-dir <path>", "Job directory (default: cwd)").option("--media <path>", "Local media to extract from (default: <job>/vertical_src.mp4, else the init source)").option("--language <code>", "Optional ISO language code").option("--num-speakers <n>", "Optional speaker count").option("--poll-interval <s>", "Run poll interval in seconds", "5").option("--json", "Print JSON")
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, wav_path: res.wavPath, duration_s: res.durationS, pulled: res.pulled.pulled.map((p) => p.path), missing: res.pulled.missing }, null, 2));
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 (!existsSync(tlPath)) throw new Error(`timeline not found: ${tlPath}`);
265
- const raw = JSON.parse(readFileSync(tlPath, "utf8"));
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: upload the preview WAV, run, 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")
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("compose").description("Fetch the server-authored HyperFrames composition, build media proxies locally; with --render, render via the `hyperframes` CLI").option("--job-dir <path>", "Job directory (default: cwd)").option("--render", "Render the composition locally (requires the `hyperframes` CLI on PATH)").option("--quality <q>", "Render quality: draft|standard|high", "standard").option("--fps <n>", "Render frame rate", "30").option("--out <path>", "Rendered output path (default <job>/preview.mp4)").option("--proxy-crf <crf>", "libx264 CRF for the compressed video proxy", "23").option("--json", "Print JSON")
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
- requireFfmpeg();
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 composeCloud(client, jobDir, {
305
- render: !!o.render,
306
- quality: o.quality,
307
- fps: num(o.fps),
308
- out: o.out,
309
- proxyCrf: num(o.proxyCrf),
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(JSON.stringify({ ok: true, composition_dir: res.compositionDir, entry: res.entry, counts: res.counts, out: res.out ?? null }, null, 2));
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(`composition: ${res.compositionDir}`);
322
- console.log(`entry: ${res.entry}`);
323
- if (res.out) console.log(`out: ${res.out}`);
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("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;
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("Render a filmstrip PNG for a window (local ffmpeg tile filter)").requiredOption("--start <s>", "Window start (seconds)").requiredOption("--end <s>", "Window end (seconds)").option("--frames <n>", "Number of frames", "10").option("--cols <n>", "Grid columns", "5").option("--video <which>", "preview|vertical_src", "preview").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
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, { requireVideoId: false });
340
- const start = num(o.start);
341
- const end = num(o.end);
342
- if (start === void 0 || end === void 0 || end <= start) throw new Error("--start and --end must be numbers with end > start");
343
- const res = await qaFramesLocal(jobDir, {
344
- video: qaVideoPath(jobDir, String(o.video)),
345
- start,
346
- end,
347
- frames: num(o.frames),
348
- cols: num(o.cols)
349
- });
350
- if (o.json) console.log(JSON.stringify({ ok: true, png: res.png }, null, 2));
351
- else console.log(`frames: ${res.png}`);
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("waveform").description("Render a waveform PNG + word-timing sidecar for a window (local ffmpeg showwavespic)").requiredOption("--start <s>", "Window start (seconds)").requiredOption("--end <s>", "Window end (seconds)").option("--video <which>", "preview|vertical_src", "preview").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
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, { requireVideoId: false });
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 job = readJobFile(jobDir);
365
- const res = await qaWaveformLocal(jobDir, {
366
- video: qaVideoPath(jobDir, String(o.video)),
367
- start,
368
- end,
369
- stem: job?.stem
370
- });
371
- if (o.json) console.log(JSON.stringify({ ok: true, png: res.png, words_path: res.wordsPath ?? null, words: res.words ?? null }, null, 2));
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: ${res.png}`);
374
- if (res.wordsPath) console.log(`words: ${res.wordsPath} (${res.words} word(s))`);
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" : existsSync(timelineJsonPath2(jobDir)) ? "kept (already present locally)" : "not provided"}`);
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 (!existsSync(path)) throw new Error(`no instruction found at ${path} \u2014 run \`video-editing review pull\` first`);
504
- if (o.json) console.log(JSON.stringify({ ok: true, instruction: path, content: readFileSync(path, "utf8") }, null, 2));
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(readFileSync(path, "utf8"));
624
+ process.stdout.write(readFileSync2(path, "utf8"));
508
625
  }
509
626
  });
510
627
  });
511
- var cloudCmd = program.command("cloud").description("Cloud render + status (clipready render API)");
512
- addCloudOptions(
513
- 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")
514
- ).action(async (o) => {
515
- await withCloudErrors(!!o.json, async () => {
516
- const mode = o.mode;
517
- if (mode !== "preview" && mode !== "final") throw new Error("--mode must be preview|final");
518
- const jobDir = jobDirOf(o);
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: ${state.status}`);
548
- console.log(`out: ${dl.path} (${dl.bytes} bytes)`);
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`] = existsSync(join(jobDir, "transcripts", `${job.stem}.json`));
608
- for (const f of STATUS_FILES) artifacts[f] = existsSync(join(jobDir, 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());