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/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
- qaFramesLocal,
23
- qaWaveformLocal,
26
+ qaDir,
24
27
  readJobFile,
25
- requireFfmpeg,
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
- } from "./chunk-SSXAUKGV.js";
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: "2.0.0" };
92
+ pkg = { version: "3.0.0" };
83
93
  }
84
94
  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);
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 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")
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
- master: !!o.master,
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("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")
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, wav_path: res.wavPath, duration_s: res.durationS, pulled: res.pulled.pulled.map((p) => p.path), missing: res.pulled.missing }, null, 2));
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: 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")
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
- 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")
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 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),
310
- verbose: !o.json
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, composition_dir: res.compositionDir, entry: res.entry, counts: res.counts, out: res.out ?? null }, null, 2));
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
- console.log(`composition: ${res.compositionDir}`);
322
- console.log(`entry: ${res.entry}`);
323
- if (res.out) console.log(`out: ${res.out}`);
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("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")
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, { 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}`);
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("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")
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, { requireVideoId: false });
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 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));
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: ${res.png}`);
374
- if (res.wordsPath) console.log(`words: ${res.wordsPath} (${res.words} word(s))`);
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
- 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));
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: ${state.status}`);
548
- console.log(`out: ${dl.path} (${dl.bytes} bytes)`);
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());