video-editing 1.3.1 → 2.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
@@ -1,1306 +1,522 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- COMPOSE_ENTRY,
4
- DEFAULT_DESILENCE,
5
- artifactPaths,
6
- checkCapabilities,
7
- compileTimeline,
8
- composeCounts,
9
- composeHtml,
10
- configDir,
11
- configEnvPath,
12
- cutBoundaries,
13
- desilenceEdl,
14
- edlPath,
15
- ensureTranscribed,
16
- ensureVerticalMaster,
17
- formatDiagnostics,
18
- gatherCompileCtx,
19
- generateQcImages,
20
- loadTimeline,
21
- planPath,
22
- readEdl,
23
- render,
24
- resolvePhrase,
25
- statusPayload,
26
- timelineJsonPath,
27
- timelineView,
28
- transcriptFileForSource,
29
- updateCaptionsBlock,
30
- usesFromDoc,
31
- validateTimeline,
32
- verify,
33
- wordStream,
34
- writeAgentManifest,
35
- writeBoundaries,
36
- writeCompileArtifacts,
37
- writeSilencePrompt,
38
- writeVideoEditingPrompt,
39
- writeWarningsFromReview
40
- } from "./chunk-63HIQBDT.js";
41
- import {
42
- initJob,
43
- loadJob,
44
- mediaDuration,
3
+ CloudClient,
4
+ REVIEW_STAGES2,
5
+ compileRemote,
6
+ composeCloud,
7
+ defaultRenderOut,
8
+ initCloudJob,
9
+ installSkill,
10
+ instructionPath2,
11
+ jobJsonPath,
12
+ loadPublicConfigEnv,
13
+ loadTimeline2,
14
+ pollRender,
15
+ publicConfigEnvPath,
16
+ pullFiles,
17
+ pullReview2,
18
+ pushComplete2,
19
+ pushFiles,
20
+ pushStage2,
21
+ pushTimeline2,
22
+ qaFramesLocal,
23
+ qaWaveformLocal,
24
+ readJobFile,
45
25
  requireFfmpeg,
46
- resolveJob,
47
- run,
48
- runSync,
49
- shown
50
- } from "./chunk-CU3NBKB4.js";
26
+ resolveConfig,
27
+ resolveRunId2,
28
+ savePublicConfigKey,
29
+ timelineJsonPath2,
30
+ transcribeCloud,
31
+ validateLocal,
32
+ verifyCloud,
33
+ videoIdFromJob
34
+ } from "./chunk-SSXAUKGV.js";
51
35
 
52
- // src/cli.ts
53
- import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync2, statSync as statSync3, writeFileSync as writeFileSync4 } from "fs";
54
- import { dirname as dirname4, join as join5, resolve as resolvePath } from "path";
36
+ // src/public/cli.ts
37
+ import { existsSync, readFileSync, writeFileSync } from "fs";
38
+ import { join, resolve as resolvePath } from "path";
55
39
  import { Command } from "commander";
56
-
57
- // src/cloud/client.ts
58
- import { createWriteStream, mkdirSync, statSync, writeFileSync } from "fs";
59
- import { dirname, join as pathJoin } from "path";
60
- import { Readable } from "stream";
61
- import { pipeline } from "stream/promises";
62
- var DEFAULT_TIMEOUT_MS = 3e4;
63
- var DEFAULT_DOWNLOAD_TIMEOUT_MS = 3e5;
64
- function firstNonEmpty(...vals) {
65
- for (const v of vals) {
66
- if (typeof v === "string" && v.trim() !== "") return v.trim();
67
- }
68
- return void 0;
69
- }
70
- function resolveApiBase(flag, env = process.env) {
71
- return firstNonEmpty(flag, env.CLIPREADY_API_BASE, env.API_BASE);
72
- }
73
- function resolveApiKey(flag, env = process.env) {
74
- return firstNonEmpty(flag, env.CLIPREADY_API_KEY, env.RUN_TOKEN);
75
- }
76
- function resolveVideoId(flag, env = process.env) {
77
- return firstNonEmpty(flag, env.VIDEO_ID);
78
- }
79
- function stripTrailingSlash(base) {
80
- return base.replace(/\/+$/, "");
81
- }
82
- function resolveConfig(flags, opts = {}, env = process.env) {
83
- const base = resolveApiBase(flags.apiBase, env);
84
- if (!base) {
85
- throw new Error("missing API base URL \u2014 pass --api-base or set CLIPREADY_API_BASE (or API_BASE)");
86
- }
87
- const credential = resolveApiKey(flags.apiKey, env);
88
- if (!credential) {
89
- throw new Error("missing API credential \u2014 pass --api-key or set CLIPREADY_API_KEY (or RUN_TOKEN)");
90
- }
91
- const videoId = resolveVideoId(flags.videoId, env);
92
- if (opts.requireVideoId && !videoId) {
93
- throw new Error("missing video id \u2014 pass --video-id or set VIDEO_ID");
94
- }
95
- return { base: stripTrailingSlash(base), credential, videoId };
96
- }
97
- function rendersUrl(base) {
98
- return `${stripTrailingSlash(base)}/api/v1/renders`;
99
- }
100
- function renderUrl(base, renderId) {
101
- return `${stripTrailingSlash(base)}/api/v1/renders/${encodeURIComponent(renderId)}`;
102
- }
103
- function qaUrl(base, videoId) {
104
- return `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/qa`;
105
- }
106
- function reviewUrl(base, videoId) {
107
- return `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/review`;
108
- }
109
- function artifactsUrl(base, videoId, name) {
110
- const root = `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/artifacts`;
111
- return name ? `${root}?name=${encodeURIComponent(name)}` : root;
112
- }
113
- function assetsUrl(base) {
114
- return `${stripTrailingSlash(base)}/api/v1/assets`;
115
- }
116
- function artifactLocalPath(jobDir, name) {
117
- const parts = name.split("/").filter((p) => p !== "" && p !== "." && p !== "..");
118
- return pathJoin(jobDir, ...parts);
119
- }
120
- function asRecord(data, ctx) {
121
- if (data === null || typeof data !== "object") throw new Error(`unexpected ${ctx} response: ${JSON.stringify(data)}`);
122
- return data;
123
- }
124
- function parseRenderCreate(data) {
125
- const o = asRecord(data, "render-create");
126
- const renderId = o.render_id;
127
- if (typeof renderId !== "string" || renderId === "") throw new Error(`render response missing render_id: ${JSON.stringify(data)}`);
128
- return { renderId, status: o.status ?? "pending" };
129
- }
130
- function parseRenderState(data) {
131
- const o = asRecord(data, "render-status");
132
- const status = o.status;
133
- if (typeof status !== "string") throw new Error(`render status response missing status: ${JSON.stringify(data)}`);
134
- return {
135
- status,
136
- videoUrl: typeof o.video_url === "string" ? o.video_url : void 0,
137
- error: o.error
138
- };
139
- }
140
- function isTerminalStatus(s) {
141
- return s === "completed" || s === "failed";
142
- }
143
- function parseArtifactList(data) {
144
- const o = asRecord(data, "artifact-list");
145
- const arr = o.artifacts;
146
- if (!Array.isArray(arr)) throw new Error(`artifact list response missing artifacts[]: ${JSON.stringify(data)}`);
147
- return arr.filter((n) => typeof n === "string");
148
- }
149
- function parseArtifactContent(data) {
150
- const o = asRecord(data, "artifact");
151
- const name = o.name;
152
- if (typeof name !== "string") throw new Error(`artifact response missing name: ${JSON.stringify(data)}`);
153
- return { name, content: o.content };
154
- }
155
- function parseAssetCreate(data) {
156
- const o = asRecord(data, "asset-create");
157
- const assetId = o.asset_id;
158
- const uploadUrl = o.upload_url;
159
- if (typeof assetId !== "string" || assetId === "") {
160
- throw new Error(`asset create response missing asset_id: ${JSON.stringify(data)}`);
161
- }
162
- if (typeof uploadUrl !== "string" || uploadUrl === "") {
163
- throw new Error(`asset create response missing upload_url: ${JSON.stringify(data)}`);
164
- }
165
- return { assetId, uploadUrl };
166
- }
167
- function parseQaFrames(data) {
168
- const o = asRecord(data, "qa-frames");
169
- if (typeof o.url !== "string") throw new Error(`qa frames response missing url: ${JSON.stringify(data)}`);
170
- return {
171
- url: o.url,
172
- width: Number(o.width ?? 0),
173
- height: Number(o.height ?? 0),
174
- frames: Number(o.frames ?? 0),
175
- tStart: Number(o.tStart ?? 0),
176
- tEnd: Number(o.tEnd ?? 0)
177
- };
178
- }
179
- function parseQaWaveform(data) {
180
- const o = asRecord(data, "qa-waveform");
181
- if (typeof o.url !== "string") throw new Error(`qa waveform response missing url: ${JSON.stringify(data)}`);
182
- const words = Array.isArray(o.words) ? o.words.map((w) => {
183
- const r = w;
184
- return { t: Number(r.t ?? 0), end: Number(r.end ?? 0), text: String(r.text ?? "") };
185
- }) : [];
186
- return { url: o.url, words };
187
- }
188
- function numOrNull(v) {
189
- return typeof v === "number" && Number.isFinite(v) ? v : null;
40
+ loadPublicConfigEnv();
41
+ function num(v) {
42
+ if (v === void 0) return void 0;
43
+ const n = Number(v);
44
+ return Number.isFinite(n) ? n : void 0;
190
45
  }
191
- function strOrNull(v) {
192
- return typeof v === "string" ? v : null;
46
+ function cloudFlags(o) {
47
+ return { apiBase: o.apiBase, apiKey: o.apiKey, videoId: o.videoId };
193
48
  }
194
- function parseReviewSession(data) {
195
- const o = asRecord(data, "review-session");
196
- const videoId = o.video_id;
197
- if (typeof videoId !== "string" || videoId === "") {
198
- throw new Error(`review session response missing video_id: ${JSON.stringify(data)}`);
199
- }
200
- let activeRound = null;
201
- if (o.active_round !== null && o.active_round !== void 0) {
202
- const r = asRecord(o.active_round, "review-session active_round");
203
- const roundId = r.round_id;
204
- const runId = r.run_id;
205
- if (typeof roundId !== "string" || typeof runId !== "string") {
206
- throw new Error(`review session active_round missing round_id/run_id: ${JSON.stringify(o.active_round)}`);
207
- }
208
- const changes = Array.isArray(r.changes) ? r.changes.filter((c) => c !== null && typeof c === "object").map((c) => ({
209
- id: String(c.id ?? ""),
210
- idx: Number(c.idx ?? 0),
211
- kind: String(c.kind ?? ""),
212
- t: numOrNull(c.t),
213
- t2: numOrNull(c.t2),
214
- x: numOrNull(c.x),
215
- y: numOrNull(c.y),
216
- text: strOrNull(c.text),
217
- outcome: strOrNull(c.outcome)
218
- })) : [];
219
- activeRound = {
220
- roundId,
221
- runId,
222
- seq: Number(r.seq ?? 0),
223
- mode: String(r.mode ?? ""),
224
- status: String(r.status ?? ""),
225
- instruction: typeof r.instruction === "string" ? r.instruction : "",
226
- changes
227
- };
228
- }
229
- return {
230
- videoId,
231
- title: strOrNull(o.title),
232
- latestVersionSeq: numOrNull(o.latest_version_seq),
233
- activeRound,
234
- timeline: o.timeline ?? null,
235
- sourceUrl: strOrNull(o.source_url)
236
- };
49
+ function addCloudOptions(cmd) {
50
+ return cmd.option("--api-base <url>", "API base URL (else CLIPREADY_API_BASE / API_BASE)").option("--api-key <key>", "API credential (else CLIPREADY_API_KEY / RUN_TOKEN)").option("--video-id <id>", "Video id (else VIDEO_ID, else <job>/job.json)");
237
51
  }
238
- var CloudHttpError = class extends Error {
239
- constructor(status, message, body) {
240
- super(message);
241
- this.status = status;
242
- this.body = body;
243
- this.name = "CloudHttpError";
52
+ function jobConfig(o, jobDir, opts = { requireVideoId: true }) {
53
+ const flags = cloudFlags(o);
54
+ const job = readJobFile(jobDir);
55
+ if (!flags.apiBase && !process.env.CLIPREADY_API_BASE && !process.env.API_BASE && job?.api_base) {
56
+ flags.apiBase = job.api_base;
244
57
  }
245
- status;
246
- body;
247
- };
248
- function extractErrorMessage(data) {
249
- if (typeof data === "string" && data.trim() !== "") return data;
250
- if (data && typeof data === "object") {
251
- const o = data;
252
- if (typeof o.error === "string") return o.error;
253
- if (typeof o.message === "string") return o.message;
254
- if (o.error) return JSON.stringify(o.error);
255
- }
256
- return void 0;
58
+ const cfg = resolveConfig(flags, { requireVideoId: false });
59
+ const videoId = opts.requireVideoId ? videoIdFromJob(jobDir, cfg.videoId) : cfg.videoId;
60
+ return { ...cfg, videoId };
257
61
  }
258
- var CloudClient = class {
259
- constructor(cfg, opts = {}) {
260
- this.cfg = cfg;
261
- this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
262
- this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
263
- if (!this.fetchImpl) throw new Error("global fetch is unavailable \u2014 Node 18+ required, or inject fetchImpl");
264
- }
265
- cfg;
266
- fetchImpl;
267
- timeoutMs;
268
- requireVideoId() {
269
- if (!this.cfg.videoId) throw new Error("missing video id \u2014 pass --video-id or set VIDEO_ID");
270
- return this.cfg.videoId;
271
- }
272
- async request(method, url, body) {
273
- const ac = new AbortController();
274
- const timer = setTimeout(() => ac.abort(), this.timeoutMs);
275
- try {
276
- const res = await this.fetchImpl(url, {
277
- method,
278
- headers: {
279
- Authorization: `Bearer ${this.cfg.credential}`,
280
- Accept: "application/json",
281
- ...body !== void 0 ? { "Content-Type": "application/json" } : {}
282
- },
283
- body: body !== void 0 ? JSON.stringify(body) : void 0,
284
- signal: ac.signal
285
- });
286
- const text = await res.text();
287
- let data = void 0;
288
- if (text) {
289
- try {
290
- data = JSON.parse(text);
291
- } catch {
292
- data = text;
293
- }
294
- }
295
- if (!res.ok) {
296
- const detail = extractErrorMessage(data) ?? `${res.status} ${res.statusText}`;
297
- throw new CloudHttpError(res.status, `${method} ${url} failed: ${detail}`, data);
298
- }
299
- return data;
300
- } catch (err) {
301
- if (err instanceof CloudHttpError) throw err;
302
- if (err instanceof Error && err.name === "AbortError") {
303
- throw new Error(`request timed out after ${this.timeoutMs}ms: ${method} ${url}`);
304
- }
305
- throw err;
306
- } finally {
307
- clearTimeout(timer);
308
- }
309
- }
310
- // ---- render -------------------------------------------------------------
311
- async createRender(mode) {
312
- const data = await this.request("POST", rendersUrl(this.cfg.base), {
313
- video_id: this.requireVideoId(),
314
- mode
315
- });
316
- return parseRenderCreate(data);
317
- }
318
- async getRender(renderId) {
319
- const data = await this.request("GET", renderUrl(this.cfg.base, renderId));
320
- return parseRenderState(data);
321
- }
322
- // ---- qa -----------------------------------------------------------------
323
- async qaFrames(args) {
324
- const body = { kind: "frames", start: args.start, end: args.end };
325
- if (args.cols !== void 0) body.cols = args.cols;
326
- if (args.interval !== void 0) body.interval = args.interval;
327
- const data = await this.request("POST", qaUrl(this.cfg.base, this.requireVideoId()), body);
328
- return parseQaFrames(data);
329
- }
330
- async qaWaveform(args) {
331
- const data = await this.request("POST", qaUrl(this.cfg.base, this.requireVideoId()), {
332
- kind: "waveform",
333
- start: args.start,
334
- end: args.end
335
- });
336
- return parseQaWaveform(data);
337
- }
338
- // ---- artifacts ----------------------------------------------------------
339
- async listArtifacts() {
340
- const data = await this.request("GET", artifactsUrl(this.cfg.base, this.requireVideoId()));
341
- return parseArtifactList(data);
342
- }
343
- async getArtifact(name) {
344
- const data = await this.request("GET", artifactsUrl(this.cfg.base, this.requireVideoId(), name));
345
- return parseArtifactContent(data);
346
- }
347
- // ---- assets -------------------------------------------------------------
348
- /** POST /api/v1/assets: request an asset row + presigned upload URL
349
- * (clipready assets route: {kind, content_type?, video_id?, filename?} →
350
- * {asset_id, upload_url, upload_token, expires_at}). */
351
- async createAsset(args) {
352
- const body = { kind: args.kind, video_id: this.requireVideoId() };
353
- if (args.contentType !== void 0) body.content_type = args.contentType;
354
- if (args.filename !== void 0) body.filename = args.filename;
355
- const data = await this.request("POST", assetsUrl(this.cfg.base), body);
356
- return parseAssetCreate(data);
357
- }
358
- /** PUT raw bytes to a presigned upload URL. No Authorization header — the
359
- * signed URL carries its own credentials and may be a different host. */
360
- async upload(url, bytes, contentType, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
361
- const ac = new AbortController();
362
- const timer = setTimeout(() => ac.abort(), timeoutMs);
363
- try {
364
- const res = await this.fetchImpl(url, {
365
- method: "PUT",
366
- headers: { "Content-Type": contentType },
367
- body: bytes,
368
- signal: ac.signal
369
- });
370
- if (!res.ok) {
371
- throw new Error(`asset upload failed: HTTP ${res.status} ${res.statusText} for ${url}`);
372
- }
373
- } catch (err) {
374
- if (err instanceof Error && err.name === "AbortError") {
375
- throw new Error(`asset upload timed out after ${timeoutMs}ms: ${url}`);
376
- }
377
- throw err;
378
- } finally {
379
- clearTimeout(timer);
380
- }
381
- }
382
- // ---- review session -----------------------------------------------------
383
- async getReview() {
384
- const data = await this.request("GET", reviewUrl(this.cfg.base, this.requireVideoId()));
385
- return parseReviewSession(data);
386
- }
387
- /** POST a review-round event ({run_id, type, payload?}); expects {ok:true}. */
388
- async pushReview(body) {
389
- const data = await this.request("POST", reviewUrl(this.cfg.base, this.requireVideoId()), body);
390
- const o = data;
391
- if (!o || o.ok !== true) {
392
- throw new Error(`review push (${body.type}) did not return {ok:true}: ${JSON.stringify(data)}`);
393
- }
394
- }
395
- // ---- download (signed URLs; no auth header) -----------------------------
396
- async download(url, dest, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
397
- return downloadTo(url, dest, { fetchImpl: this.fetchImpl, timeoutMs });
398
- }
399
- };
400
- async function downloadTo(url, dest, opts = {}) {
401
- const f = opts.fetchImpl ?? globalThis.fetch;
402
- const ac = new AbortController();
403
- const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS);
62
+ async function withCloudErrors(json, fn) {
404
63
  try {
405
- const res = await f(url, { signal: ac.signal });
406
- if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText} for ${url}`);
407
- mkdirSync(dirname(dest), { recursive: true });
408
- const body = res.body;
409
- if (!body) {
410
- const buf = Buffer.from(await res.arrayBuffer());
411
- writeFileSync(dest, buf);
412
- return { path: dest, bytes: buf.length };
413
- }
414
- await pipeline(Readable.fromWeb(body), createWriteStream(dest));
415
- return { path: dest, bytes: statSync(dest).size };
64
+ await fn();
416
65
  } catch (err) {
417
- if (err instanceof Error && err.name === "AbortError") {
418
- throw new Error(`download timed out after ${opts.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS}ms: ${url}`);
66
+ if (json) {
67
+ const message = err instanceof Error ? err.message : String(err);
68
+ console.log(JSON.stringify({ ok: false, error: message }, null, 2));
69
+ process.exitCode = 1;
70
+ return;
419
71
  }
420
72
  throw err;
421
- } finally {
422
- clearTimeout(timer);
423
73
  }
424
74
  }
425
-
426
- // src/cloud/review.ts
427
- import { createHash } from "crypto";
428
- import { existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
429
- import { basename, isAbsolute, join } from "path";
430
- function reviewDir(jobDir) {
431
- return join(jobDir, "review");
432
- }
433
- function roundJsonPath(jobDir) {
434
- return join(reviewDir(jobDir), "round.json");
435
- }
436
- function instructionPath(jobDir) {
437
- return join(reviewDir(jobDir), "instruction.md");
438
- }
439
- function assetsCachePath(jobDir) {
440
- return join(reviewDir(jobDir), "assets.json");
441
- }
442
- function sourceDownloadPath(jobDir) {
443
- const manifest = join(jobDir, "source.json");
444
- if (existsSync(manifest)) {
445
- try {
446
- const src = JSON.parse(readFileSync(manifest, "utf8")).source;
447
- if (typeof src === "string" && src !== "") return join(jobDir, basename(src));
448
- } catch {
449
- }
450
- }
451
- return join(jobDir, "source.mp4");
75
+ function jobDirOf(o) {
76
+ return o.jobDir ? resolvePath(String(o.jobDir)) : process.cwd();
452
77
  }
453
- async function pullReview(client, jobDir, opts = {}) {
454
- const session = await client.getReview();
455
- mkdirSync2(reviewDir(jobDir), { recursive: true });
456
- const round = session.activeRound;
457
- if (round) {
458
- const roundFile = {
459
- video_id: session.videoId,
460
- round_id: round.roundId,
461
- run_id: round.runId,
462
- seq: round.seq,
463
- mode: round.mode,
464
- status: round.status,
465
- pulled_at: (/* @__PURE__ */ new Date()).toISOString(),
466
- changes: round.changes
467
- };
468
- writeFileSync2(roundJsonPath(jobDir), JSON.stringify(roundFile, null, 2) + "\n", "utf8");
469
- writeFileSync2(instructionPath(jobDir), round.instruction + "\n", "utf8");
470
- } else {
471
- writeFileSync2(instructionPath(jobDir), "No active review round for this video.\n", "utf8");
472
- }
473
- let wroteTimeline = false;
474
- const tlPath = timelineJsonPath(jobDir);
475
- if (session.timeline !== null && session.timeline !== void 0 && !existsSync(tlPath)) {
476
- writeFileSync2(tlPath, JSON.stringify(session.timeline, null, 2) + "\n", "utf8");
477
- wroteTimeline = true;
478
- }
479
- let downloadedSource = null;
480
- if (opts.withSource && session.sourceUrl) {
481
- const dest = sourceDownloadPath(jobDir);
482
- if (!existsSync(dest)) {
483
- const dl = await client.download(session.sourceUrl, dest);
484
- downloadedSource = dl.path;
485
- }
486
- }
487
- return { jobDir, round, wroteTimeline, downloadedSource };
78
+ var pkg;
79
+ try {
80
+ pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
81
+ } catch {
82
+ pkg = { version: "2.0.0" };
488
83
  }
489
- function resolveRunId(jobDir, flagOverride) {
490
- if (typeof flagOverride === "string" && flagOverride.trim() !== "") return flagOverride.trim();
491
- const path = roundJsonPath(jobDir);
492
- if (existsSync(path)) {
84
+ 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);
86
+ 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")
88
+ ).action(async (o) => {
89
+ await withCloudErrors(!!o.json, async () => {
90
+ requireFfmpeg();
91
+ const cfg = resolveConfig(cloudFlags(o), { requireVideoId: false });
92
+ const client = new CloudClient(cfg);
93
+ let res;
493
94
  try {
494
- const runId = JSON.parse(readFileSync(path, "utf8")).run_id;
495
- if (typeof runId === "string" && runId !== "") return runId;
496
- } catch {
497
- }
498
- }
499
- throw new Error(`missing run id \u2014 pass --run-id, or run \`review pull\` first (no run_id in ${path})`);
500
- }
501
- var REVIEW_STAGES = [
502
- "provisioning",
503
- "transcribing",
504
- "edl",
505
- "render_preview",
506
- "qc",
507
- "render_final",
508
- "uploading",
509
- "log"
510
- ];
511
- function isReviewStage(s) {
512
- return REVIEW_STAGES.includes(s);
513
- }
514
- async function pushStage(client, runId, stage, message) {
515
- if (!isReviewStage(stage)) {
516
- throw new Error(`invalid --stage "${stage}" \u2014 expected one of: ${REVIEW_STAGES.join(", ")}`);
517
- }
518
- await client.pushReview({
519
- run_id: runId,
520
- type: stage,
521
- ...message !== void 0 ? { payload: { message } } : {}
522
- });
523
- }
524
- function collectPlanAssetRefs(plan) {
525
- const refs = [];
526
- for (const ev of plan.events) {
527
- const ref = ev.kind === "overlay" ? ev.overlay.asset?.ref : ev.kind === "audio" ? ev.audio.asset.ref : void 0;
528
- if (typeof ref === "string" && ref !== "" && !refs.includes(ref)) refs.push(ref);
529
- }
530
- return refs;
531
- }
532
- var ASSET_CONTENT_TYPES = {
533
- ".png": "image/png",
534
- ".jpg": "image/jpeg",
535
- ".jpeg": "image/jpeg",
536
- ".webp": "image/webp",
537
- ".gif": "image/gif",
538
- ".mp3": "audio/mpeg",
539
- ".m4a": "audio/mp4",
540
- ".wav": "audio/wav"
541
- };
542
- function assetContentType(ref) {
543
- const m = /\.[^./\\]+$/.exec(ref.toLowerCase());
544
- return (m !== null ? ASSET_CONTENT_TYPES[m[0]] : void 0) ?? "application/octet-stream";
545
- }
546
- function readAssetCache(jobDir) {
547
- const path = assetsCachePath(jobDir);
548
- if (!existsSync(path)) return {};
549
- try {
550
- const raw = JSON.parse(readFileSync(path, "utf8"));
551
- const out = {};
552
- for (const [ref, v] of Object.entries(raw ?? {})) {
553
- const e = v;
554
- if (e && typeof e.sha256 === "string" && typeof e.asset_id === "string") {
555
- out[ref] = { sha256: e.sha256, asset_id: e.asset_id };
95
+ res = await initCloudJob(client, {
96
+ source: o.source,
97
+ jobDir: o.jobDir,
98
+ title: o.title,
99
+ master: !!o.master,
100
+ verbose: !o.json
101
+ });
102
+ } catch (err) {
103
+ if (err instanceof Error && /\/v1\/me|401|403|invalid.*(key|token)|unauthorized/i.test(err.message)) {
104
+ throw new Error(`${err.message}
105
+ Your credential was rejected \u2014 get your API key at ${cfg.base}/settings and run \`video-editing config set-key <key>\`.`);
556
106
  }
107
+ throw err;
557
108
  }
558
- return out;
559
- } catch {
560
- return {};
561
- }
562
- }
563
- async function syncAssets(client, jobDir, plan) {
564
- const refs = collectPlanAssetRefs(plan);
565
- if (refs.length === 0) return {};
566
- const paths = /* @__PURE__ */ new Map();
567
- for (const ref of refs) {
568
- const abs = isAbsolute(ref) ? ref : join(jobDir, ref);
569
- if (!existsSync(abs)) {
570
- throw new Error(
571
- `asset not found: ${ref} (expected at ${abs}) \u2014 asset refs are job-dir-relative paths; place the file before pushing`
572
- );
573
- }
574
- paths.set(ref, abs);
575
- }
576
- const cache = readAssetCache(jobDir);
577
- const manifest = {};
578
- let dirty = false;
579
- for (const ref of refs) {
580
- const bytes = readFileSync(paths.get(ref));
581
- const sha256 = "sha256:" + createHash("sha256").update(bytes).digest("hex");
582
- const hit = cache[ref];
583
- if (hit !== void 0 && hit.sha256 === sha256) {
584
- manifest[ref] = hit.asset_id;
585
- continue;
586
- }
587
- const contentType = assetContentType(ref);
588
- const created = await client.createAsset({
589
- kind: "timeline",
590
- contentType,
591
- filename: basename(ref)
592
- });
593
- await client.upload(created.uploadUrl, bytes, contentType);
594
- cache[ref] = { sha256, asset_id: created.assetId };
595
- manifest[ref] = created.assetId;
596
- dirty = true;
597
- }
598
- if (dirty) {
599
- mkdirSync2(reviewDir(jobDir), { recursive: true });
600
- writeFileSync2(assetsCachePath(jobDir), JSON.stringify(cache, null, 2) + "\n", "utf8");
601
- }
602
- return manifest;
603
- }
604
- async function compileJob(jobDir, opts) {
605
- const loaded = loadTimeline(jobDir);
606
- if (!loaded.doc) return { ok: false, errors: loaded.errors, diagnostics: null };
607
- const ctx = await gatherCompileCtx(jobDir, loaded.doc, opts.gather ?? {});
608
- const result = compileTimeline(loaded.doc, ctx);
609
- writeCompileArtifacts(jobDir, result);
610
- if (!result.ok || !result.plan) {
611
- return { ok: false, errors: result.diagnostics.errors, diagnostics: result.diagnostics };
612
- }
613
- return { ok: true, raw: loaded.raw, plan: result.plan, legacyEdl: result.legacyEdl };
614
- }
615
- async function pushTimeline(client, jobDir, runId, opts = {}) {
616
- const compiled = await compileJob(jobDir, opts);
617
- if (!compiled.ok) return compiled;
618
- const assets = await syncAssets(client, jobDir, compiled.plan);
619
- await client.pushReview({
620
- run_id: runId,
621
- type: "timeline_update",
622
- payload: {
623
- timeline: compiled.raw,
624
- plan: compiled.plan,
625
- timeline_hash: compiled.plan.timelineHash,
626
- ...Object.keys(assets).length > 0 ? { assets } : {}
109
+ 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));
111
+ } else {
112
+ console.log(`job: ${res.jobDir}`);
113
+ console.log(`video_id: ${res.videoId}`);
114
+ console.log(`project_id: ${res.projectId}`);
115
+ console.log(`job.json: ${res.jobJson}`);
627
116
  }
628
117
  });
629
- return { ok: true, plan: compiled.plan, timelineHash: compiled.plan.timelineHash, assets };
630
- }
631
- function readOutcomesFile(path) {
632
- if (!existsSync(path)) throw new Error(`outcomes file not found: ${path}`);
633
- let raw;
634
- try {
635
- raw = JSON.parse(readFileSync(path, "utf8"));
636
- } catch (err) {
637
- throw new Error(`outcomes file is not valid JSON: ${path}: ${err instanceof Error ? err.message : String(err)}`);
638
- }
639
- const arr = Array.isArray(raw) ? raw : raw?.outcomes;
640
- if (!Array.isArray(arr)) {
641
- throw new Error(`outcomes file must be an array (or {outcomes: [...]}) of {change_id, ok, note?}: ${path}`);
642
- }
643
- return arr.map((o, i) => {
644
- const r = o ?? {};
645
- if (typeof r.change_id !== "string" || typeof r.ok !== "boolean" || r.note !== void 0 && typeof r.note !== "string") {
646
- throw new Error(`outcomes[${i}] must be {change_id: string, ok: boolean, note?: string}: ${JSON.stringify(o)}`);
118
+ });
119
+ 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")
121
+ ).action(async (o) => {
122
+ await withCloudErrors(!!o.json, async () => {
123
+ requireFfmpeg();
124
+ const jobDir = jobDirOf(o);
125
+ const cfg = jobConfig(o, jobDir);
126
+ const client = new CloudClient(cfg);
127
+ 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
+ const res = await transcribeCloud(client, jobDir, {
136
+ media,
137
+ stem: job?.stem ?? "source",
138
+ language: o.language,
139
+ numSpeakers: num(o.numSpeakers),
140
+ pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
141
+ verbose: !o.json
142
+ });
143
+ 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
+ } else {
146
+ console.log(`run: ${res.runId}`);
147
+ for (const p of res.pulled.pulled) console.log(` ${p.path} \u2192 ${p.localPath}${p.skipped ? " (unchanged)" : ""}`);
148
+ if (res.pulled.missing.length > 0) console.log(` missing on server: ${res.pulled.missing.join(", ")}`);
647
149
  }
648
- return { change_id: r.change_id, ok: r.ok, ...r.note !== void 0 ? { note: r.note } : {} };
649
150
  });
151
+ });
152
+ var timelineCmd = program.command("timeline").description("TimelineV2 authoring: validate locally, resolve/compile on the server");
153
+ function printTimelineErrors(errors, json) {
154
+ if (json) console.log(JSON.stringify({ ok: false, errors, warnings: [] }, null, 2));
155
+ else for (const e of errors) console.log(` [error] ${e.path}: ${e.message}`);
650
156
  }
651
- async function pushComplete(client, jobDir, runId, opts) {
652
- const outcomes = opts.outcomesPath ? readOutcomesFile(opts.outcomesPath) : void 0;
653
- const compiled = await compileJob(jobDir, opts);
654
- if (!compiled.ok) return compiled;
655
- const assets = await syncAssets(client, jobDir, compiled.plan);
656
- await client.pushReview({
657
- run_id: runId,
658
- type: "completed",
659
- payload: {
660
- edl: compiled.legacyEdl,
661
- timeline: compiled.raw,
662
- plan: compiled.plan,
663
- duration_s: opts.durationS,
664
- ...outcomes !== void 0 ? { outcomes } : {},
665
- ...Object.keys(assets).length > 0 ? { assets } : {}
157
+ addCloudOptions(
158
+ timelineCmd.command("validate").description("Schema + semantic + capability validation of <job>/timeline.json (local)").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print the JSON verdict")
159
+ ).action(async (o) => {
160
+ await withCloudErrors(!!o.json, async () => {
161
+ const jobDir = jobDirOf(o);
162
+ jobConfig(o, jobDir, { requireVideoId: false });
163
+ const loaded = loadTimeline2(jobDir);
164
+ if (!loaded.doc) {
165
+ printTimelineErrors(loaded.errors, !!o.json);
166
+ process.exitCode = 2;
167
+ return;
666
168
  }
667
- });
668
- return { ok: true, plan: compiled.plan, durationS: opts.durationS, outcomes, assets };
669
- }
670
-
671
- // src/cloud/render.ts
672
- import { join as join2 } from "path";
673
- var MAX_POLL_MS = 30 * 60 * 1e3;
674
- function defaultRenderOut(jobDir, mode) {
675
- return join2(jobDir, mode === "final" ? "final.mp4" : "preview.mp4");
676
- }
677
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
678
- async function pollRender(client, renderId, opts = {}) {
679
- const intervalMs = opts.intervalMs ?? 1e4;
680
- const maxMs = opts.maxMs ?? MAX_POLL_MS;
681
- const nap = opts.sleepImpl ?? sleep;
682
- const now = opts.now ?? Date.now;
683
- const start = now();
684
- for (; ; ) {
685
- const state = await client.getRender(renderId);
686
- const elapsed = now() - start;
687
- opts.onTick?.(state, elapsed);
688
- if (isTerminalStatus(state.status)) return state;
689
- if (elapsed + intervalMs >= maxMs) {
690
- throw new Error(`render ${renderId} did not finish within ${Math.round(maxMs / 6e4)} min (last status: ${state.status})`);
169
+ const v = validateLocal(loaded.doc);
170
+ if (o.json) {
171
+ console.log(JSON.stringify({ ok: v.ok, uses: v.uses, errors: v.errors, warnings: v.warnings }, null, 2));
172
+ } else {
173
+ console.log(`timeline: ${timelineJsonPath2(jobDir)}`);
174
+ console.log(`valid: ${v.ok ? "yes" : "NO"} (${v.errors.length} error(s), ${v.warnings.length} warning(s))`);
175
+ console.log(`uses: ${v.uses.join(", ") || "(none)"}`);
176
+ for (const e of v.errors) console.log(` [error] ${e.path}: ${e.message}`);
177
+ for (const w of v.warnings) console.log(` [warn] ${w.path}: ${w.message}`);
691
178
  }
692
- await nap(intervalMs);
693
- }
694
- }
695
-
696
- // src/cloud/qa.ts
697
- import { join as join3 } from "path";
698
- function qaDir(jobDir) {
699
- return jobDir ? join3(jobDir, "qa") : join3(process.cwd(), "qa");
700
- }
701
- function tag(start, end) {
702
- return `${start}_${end}`;
703
- }
704
- function framesPngPath(dir, start, end) {
705
- return join3(dir, `frames_${tag(start, end)}.png`);
706
- }
707
- function waveformPngPath(dir, start, end) {
708
- return join3(dir, `waveform_${tag(start, end)}.png`);
709
- }
710
- function waveformWordsPath(dir, start, end) {
711
- return join3(dir, `waveform_${tag(start, end)}.words.json`);
712
- }
713
-
714
- // src/cloud/pull.ts
715
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
716
- import { dirname as dirname2 } from "path";
717
- function selectArtifacts(available, only) {
718
- if (!only || only.length === 0) return available;
719
- const want = new Set(only.map((n) => n.trim()).filter((n) => n !== ""));
720
- return available.filter((n) => want.has(n));
721
- }
722
- function parseOnly(value) {
723
- if (!value) return void 0;
724
- const names = value.split(",").map((n) => n.trim()).filter((n) => n !== "");
725
- return names.length > 0 ? names : void 0;
726
- }
727
- function serializeArtifact(name, content) {
728
- if (typeof content === "string") return content;
729
- return JSON.stringify(content, null, 2) + "\n";
730
- }
731
- async function pullArtifacts(client, jobDir, only) {
732
- const available = await client.listArtifacts();
733
- const requested = only && only.length > 0 ? only : available;
734
- const selected = selectArtifacts(available, only);
735
- const missing = requested.filter((n) => !available.includes(n));
736
- const pulled = [];
737
- for (const name of selected) {
738
- const art = await client.getArtifact(name);
739
- const body = serializeArtifact(art.name, art.content);
740
- const dest = artifactLocalPath(jobDir, art.name);
741
- mkdirSync3(dirname2(dest), { recursive: true });
742
- writeFileSync3(dest, body, "utf8");
743
- pulled.push({ name: art.name, path: dest, bytes: Buffer.byteLength(body) });
744
- }
745
- return { jobDir, pulled, missing };
746
- }
747
-
748
- // src/cloud/skill.ts
749
- import { cpSync, existsSync as existsSync2, readdirSync, rmSync, statSync as statSync2 } from "fs";
750
- import { homedir } from "os";
751
- import { dirname as dirname3, join as join4, relative, resolve } from "path";
752
- import { fileURLToPath } from "url";
753
- function bundledSkillDir() {
754
- const here = dirname3(fileURLToPath(import.meta.url));
755
- const candidates = [
756
- join4(here, "skills", "video-editing"),
757
- join4(here, "..", "skills", "video-editing"),
758
- join4(here, "..", "..", "skills", "video-editing")
759
- ];
760
- for (const c of candidates) {
761
- if (existsSync2(join4(c, "SKILL.md"))) return resolve(c);
762
- }
763
- throw new Error(`bundled skill not found (looked in: ${candidates.map((c) => resolve(c)).join(", ")})`);
764
- }
765
- function defaultSkillTarget() {
766
- return join4(homedir(), ".claude", "skills");
767
- }
768
- function listFilesRecursive(root) {
769
- const out = [];
770
- for (const entry of readdirSync(root)) {
771
- const full = join4(root, entry);
772
- if (statSync2(full).isDirectory()) out.push(...listFilesRecursive(full));
773
- else out.push(full);
774
- }
775
- return out;
776
- }
777
- function installSkill(opts = {}) {
778
- const source = bundledSkillDir();
779
- const targetRoot = opts.target ? resolve(opts.target) : defaultSkillTarget();
780
- const dest = join4(targetRoot, "video-editing");
781
- rmSync(dest, { recursive: true, force: true });
782
- cpSync(source, dest, { recursive: true });
783
- const files = listFilesRecursive(dest).map((f) => relative(dest, f)).sort();
784
- return { source, dest, files };
785
- }
786
-
787
- // src/cli.ts
788
- function num(v) {
789
- if (v === void 0) return void 0;
790
- const n = Number(v);
791
- return Number.isFinite(n) ? n : void 0;
792
- }
793
- async function maybeDesilence(jobDir, opts = {}) {
794
- const edl = readEdl(jobDir);
795
- if (!edl) return;
796
- if (edl.desilenced && !opts.force) return;
797
- const { edl: tight, stats } = await desilenceEdl(edl, jobDir, opts.options ?? DEFAULT_DESILENCE);
798
- const rawBackup = join5(jobDir, "edl.raw.json");
799
- if (!existsSync3(rawBackup)) writeFileSync4(rawBackup, JSON.stringify(edl, null, 2) + "\n", "utf8");
800
- writeFileSync4(edlPath(jobDir), JSON.stringify(tight, null, 2) + "\n", "utf8");
801
- const db = (opts.options ?? DEFAULT_DESILENCE).noiseDb;
802
- process.stderr.write(
803
- ` desilence(${db}dB): ${stats.rangesBefore} \u2192 ${stats.rangesAfter} ranges, removed ${stats.removedS.toFixed(2)}s of silence (${stats.durationBefore.toFixed(2)}s \u2192 ${stats.durationAfter.toFixed(2)}s)
804
- `
805
- );
806
- }
807
- async function renderJob(jobDir, mode, opts = {}) {
808
- if (mode === "none") return null;
809
- if (!existsSync3(edlPath(jobDir))) return null;
810
- if (!opts.noDesilence) await maybeDesilence(jobDir);
811
- const name = { preview: "preview.mp4", draft: "draft.mp4", final: "final.mp4" }[mode];
812
- const output = join5(jobDir, name);
813
- await render(edlPath(jobDir), output, {
814
- preview: mode === "preview",
815
- draft: mode === "draft",
816
- buildSubtitles: opts.buildSubtitles,
817
- noSubtitles: opts.noSubtitles,
818
- noLoudnorm: opts.noLoudnorm
819
- });
820
- return output;
821
- }
822
- function printPrepared(jobDir, prompt, edlRequired) {
823
- console.log(`job: ${jobDir}`);
824
- console.log(`prompt: ${prompt}`);
825
- console.log(`manifest: ${join5(jobDir, "agent_manifest.json")}`);
826
- console.log(`transcript: ${join5(jobDir, "transcripts")}`);
827
- console.log(`flags: ${join5(jobDir, "flags.txt")}`);
828
- console.log(`word_dump: ${join5(jobDir, "word_dump.txt")}`);
829
- console.log(`vertical_src: ${join5(jobDir, "vertical_src.mp4")}`);
830
- if (edlRequired) console.log(`edl missing: ${join5(jobDir, "edl.json")}`);
831
- }
832
- var pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
833
- var program = new Command();
834
- program.name("video-editing").description("Standalone TypeScript CLI for editing talking-head reels: transcribe, screen, cut (EDL), render vertical 9:16, QC, content-verify.").version(pkg.version);
835
- program.command("init-job").description("Create a job directory and source.json").requiredOption("--source <path>", "Source video path").option("--job-dir <path>", "Job directory (default: ~/Downloads/edit/job_<source-stem>)").option("--force", "Overwrite source.json metadata").action(async (o) => {
836
- const jobDir = await initJob(o.source, o.jobDir ?? null, { force: !!o.force });
837
- console.log(jobDir);
838
- });
839
- program.command("transcribe").description("Run ElevenLabs Scribe transcription and screening artifacts").option("--source <path>", "Source video path").option("--job-dir <path>", "Existing job directory").option("--language <code>", "Optional ISO language code").option("--num-speakers <n>", "Optional speaker count").option("--no-heal", "Disable transcript healing").option("--force", "Re-transcribe even if cached").option("--api-key <key>", "ElevenLabs API key override").action(async (o) => {
840
- const { jobDir, source, stem } = await resolveJob(o, { forceJob: !!o.force });
841
- const tpath = await ensureTranscribed(jobDir, source, stem, {
842
- language: o.language,
843
- numSpeakers: num(o.numSpeakers),
844
- noHeal: o.heal === false,
845
- force: !!o.force,
846
- apiKey: o.apiKey
179
+ if (!v.ok) process.exitCode = 2;
847
180
  });
848
- writeAgentManifest(jobDir, source, stem, "transcribe");
849
- console.log(`transcript: ${tpath}`);
850
- console.log(`packed: ${join5(jobDir, "takes_packed.md")}`);
851
- console.log(`flags: ${join5(jobDir, "flags.txt")}`);
852
181
  });
853
- program.command("cut-silences").description("Prepare a silence-cut prompt; render/QC if edl.json exists").option("--source <path>", "Source video path").option("--job-dir <path>", "Existing job directory").option("--min-gap <s>", "Document gaps at or above this many seconds", "0.30").option("--target-gap <s>", "Prompt target for remaining air", "0.13").option("--lead-pad <s>", "Prompt lead pad", "0.05").option("--trail-pad <s>", "Prompt trail pad", "0.08").option("--render <mode>", "Render if edl.json exists (preview|none)", "none").option("--force-transcribe", "Regenerate transcript before preparing").option("--api-key <key>", "ElevenLabs API key override").action(async (o) => {
854
- const { jobDir, source, stem } = await resolveJob(o);
855
- await ensureTranscribed(jobDir, source, stem, { force: !!o.forceTranscribe, apiKey: o.apiKey });
856
- await ensureVerticalMaster(jobDir, source);
857
- const prompt = writeSilencePrompt(jobDir, stem, {
858
- minGap: num(o.minGap),
859
- targetGap: num(o.targetGap),
860
- leadPad: num(o.leadPad),
861
- trailPad: num(o.trailPad)
862
- });
863
- writeAgentManifest(jobDir, source, stem, "cut-silences");
864
- const preview = await renderJob(jobDir, o.render === "preview" ? "preview" : "none");
865
- if (preview) {
866
- writeBoundaries(jobDir);
867
- console.log(`preview: ${preview}`);
868
- }
869
- printPrepared(jobDir, prompt, !existsSync3(edlPath(jobDir)));
870
- });
871
- program.command("cut-bad-retakes").description("Prepare the bad-retake (author-edl) prompt; render/QC if edl.json exists").option("--source <path>", "Source video path").option("--job-dir <path>", "Existing job directory").option("--with-sound-waves", "Write preview join QC PNGs if preview.mp4 exists").option("--quality-review", "Write review prompt; convert review.json to warnings if present").option("--render <mode>", "Render if edl.json exists (preview|none)", "none").option("--language <code>", "Optional ISO language code").option("--num-speakers <n>", "Optional speaker count").option("--force-transcribe", "Regenerate transcript before preparing").option("--api-key <key>", "ElevenLabs API key override").action(async (o) => {
872
- const { jobDir, source, stem } = await resolveJob(o);
873
- await ensureTranscribed(jobDir, source, stem, {
874
- language: o.language,
875
- numSpeakers: num(o.numSpeakers),
876
- force: !!o.forceTranscribe,
877
- apiKey: o.apiKey
878
- });
879
- await ensureVerticalMaster(jobDir, source);
880
- const prompt = writeVideoEditingPrompt(jobDir, stem, "author-edl.prompt.md", "author-edl.prompt.md");
881
- writeAgentManifest(jobDir, source, stem, "cut-bad-retakes");
882
- const preview = await renderJob(jobDir, o.render === "preview" ? "preview" : "none");
883
- if (preview) console.log(`preview: ${preview}`);
884
- else if (o.render !== "none") process.stderr.write(`edl not found yet; write ${edlPath(jobDir)} from ${prompt}
885
- `);
886
- if (o.withSoundWaves) {
887
- const qc = await generateQcImages(jobDir, preview ? { preview } : {});
888
- if (qc) console.log(`qc: ${qc}`);
889
- else process.stderr.write("qc not generated yet; it requires edl.json and preview.mp4\n");
890
- }
891
- if (o.qualityReview) {
892
- const reviewPrompt = writeVideoEditingPrompt(jobDir, stem, "review.prompt.md", "review.prompt.md");
893
- console.log(`review_prompt: ${reviewPrompt}`);
894
- if (existsSync3(join5(jobDir, "review.json"))) {
895
- console.log(`warnings: ${writeWarningsFromReview(jobDir)}`);
182
+ addCloudOptions(
183
+ timelineCmd.command("resolve").description("Resolve a transcript phrase to source seconds (server-side)").requiredOption("--phrase <text>", "Phrase to locate in the word-level transcript").option("--source <name>", "Source name (multi-source timelines)").option("--occurrence <n>", "Pick the n-th occurrence (1-based)").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
184
+ ).action(async (o) => {
185
+ await withCloudErrors(!!o.json, async () => {
186
+ const jobDir = jobDirOf(o);
187
+ const client = new CloudClient(jobConfig(o, jobDir));
188
+ const res = await client.resolveRemote({
189
+ phrase: o.phrase,
190
+ ...o.source !== void 0 ? { source: o.source } : {},
191
+ ...num(o.occurrence) !== void 0 ? { occurrence: num(o.occurrence) } : {}
192
+ });
193
+ if (o.json) {
194
+ console.log(JSON.stringify(res, null, 2));
195
+ } else if (res.start !== void 0 && res.end !== void 0) {
196
+ console.log(`resolved: ${res.start}s - ${res.end}s${res.occurrence !== void 0 ? ` (occurrence ${res.occurrence})` : ""}`);
197
+ if (typeof res.context === "string") console.log(`context: ${res.context}`);
198
+ } else if (Array.isArray(res.candidates)) {
199
+ console.log(`ambiguous: ${res.candidates.length} occurrence(s); pass --occurrence`);
200
+ for (const c of res.candidates) {
201
+ console.log(` #${c.occurrence} ${c.start}s - ${c.end}s: ${c.context ?? ""}`);
202
+ }
203
+ process.exitCode = 3;
204
+ } else if (Array.isArray(res.suggestions)) {
205
+ console.log(`not found: "${o.phrase}"`);
206
+ for (const s of res.suggestions) {
207
+ console.log(` did you mean "${s.text}" @${s.start}s (similarity ${s.similarity})`);
208
+ }
209
+ process.exitCode = 1;
210
+ } else {
211
+ console.log(JSON.stringify(res, null, 2));
896
212
  }
897
- }
898
- printPrepared(jobDir, prompt, !existsSync3(edlPath(jobDir)));
899
- });
900
- program.command("render").description("Render the EDL to a preview/draft/final video").requiredOption("--job-dir <path>", "Job directory containing edl.json").requiredOption("--mode <mode>", "preview|draft|final").option("--build-subtitles", "Burn 2-word subtitles from the transcript").option("--no-subtitles", "Do not burn subtitles").option("--no-loudnorm", "Skip loudness normalization").option("--no-desilence", "Skip the amplitude (-39dB) silence-trim pass").action(async (o) => {
901
- const jobDir = resolvePath(o.jobDir);
902
- const mode = o.mode;
903
- if (!["preview", "draft", "final"].includes(mode)) throw new Error("--mode must be preview|draft|final");
904
- const output = await renderJob(jobDir, mode, {
905
- buildSubtitles: !!o.buildSubtitles,
906
- noSubtitles: o.subtitles === false,
907
- noLoudnorm: o.loudnorm === false,
908
- noDesilence: o.desilence === false
909
- });
910
- if (!output) throw new Error(`edl not found: ${edlPath(jobDir)}`);
911
- writeBoundaries(jobDir);
912
- console.log(output);
913
- });
914
- program.command("desilence").description("Excise sub-threshold (-39dB) silences inside EDL ranges and trim silent range edges; rewrites edl.json (original \u2192 edl.raw.json). Runs automatically before render.").requiredOption("--job-dir <path>", "Job directory containing edl.json").option("--noise-db <db>", "Silence threshold in dB (silencedetect noise)", String(DEFAULT_DESILENCE.noiseDb)).option("--min-silence <s>", "Minimum silence length to cut", String(DEFAULT_DESILENCE.minSilence)).option("--target-gap <s>", "Air to leave where a silence is cut", String(DEFAULT_DESILENCE.targetGap)).option("--lead-pad <s>", "Air kept before speech resumes", String(DEFAULT_DESILENCE.leadKeep)).option("--trail-pad <s>", "Air kept after speech ends", String(DEFAULT_DESILENCE.trailKeep)).option("--render <mode>", "Render after rewriting (preview|none)", "none").option("--force", "Re-run even if the EDL is already marked desilenced").action(async (o) => {
915
- const jobDir = resolvePath(o.jobDir);
916
- if (!existsSync3(edlPath(jobDir))) throw new Error(`edl not found: ${edlPath(jobDir)}`);
917
- const options = {
918
- ...DEFAULT_DESILENCE,
919
- noiseDb: num(o.noiseDb) ?? DEFAULT_DESILENCE.noiseDb,
920
- minSilence: num(o.minSilence) ?? DEFAULT_DESILENCE.minSilence,
921
- targetGap: num(o.targetGap) ?? DEFAULT_DESILENCE.targetGap,
922
- leadKeep: num(o.leadPad) ?? DEFAULT_DESILENCE.leadKeep,
923
- trailKeep: num(o.trailPad) ?? DEFAULT_DESILENCE.trailKeep
924
- };
925
- await maybeDesilence(jobDir, { force: true, options });
926
- writeBoundaries(jobDir);
927
- const preview = await renderJob(jobDir, o.render === "preview" ? "preview" : "none", { noDesilence: true });
928
- if (preview) {
929
- writeBoundaries(jobDir);
930
- console.log(`preview: ${preview}`);
931
- }
932
- console.log(`edl: ${edlPath(jobDir)}`);
933
- console.log(`raw: ${join5(jobDir, "edl.raw.json")}`);
934
- });
935
- program.command("verify").description("Content-verify the rendered preview: re-transcribe joins, flag duplicated phrases + dead air").requiredOption("--job-dir <path>", "Job directory").option("--full", "Re-transcribe the whole preview instead of per-join windows").option("--window <s>", "Per-join window length in seconds", "6").option("--gap <s>", "Dead-air threshold in seconds", "0.6").option("--json", "Print the JSON verdict to stdout").option("--api-key <key>", "ElevenLabs API key override").action(async (o) => {
936
- const jobDir = resolvePath(o.jobDir);
937
- const result = await verify(jobDir, {
938
- full: !!o.full,
939
- windowS: num(o.window),
940
- gapThresholdS: num(o.gap),
941
- apiKey: o.apiKey
942
213
  });
943
- writeFileSync4(join5(jobDir, "verify.json"), JSON.stringify(result, null, 2) + "\n", "utf8");
944
- if (o.json) {
945
- console.log(JSON.stringify(result, null, 2));
946
- } else {
947
- console.log(`verify: ${result.verdict} (${result.joins.length} finding(s), mode ${result.mode})`);
948
- for (const f of result.joins) console.log(` [${f.severity}] ${f.detail}`);
949
- }
950
- if (result.verdict === "needs-fixes") process.exitCode = 2;
951
- });
952
- program.command("inspect").description("Render a filmstrip+waveform PNG for a job window or join").requiredOption("--job-dir <path>", "Job directory").option("--join <i>", "Center on cut-boundary index i (uses cut_boundaries)").option("--start <s>", "Window start (seconds)").option("--end <s>", "Window end (seconds)").option("--video <which>", "preview|vertical_src", "preview").option("--transcript <path>", "Transcript JSON for word labels").option("-o, --output <path>", "Output PNG path").option("--n-frames <n>", "Number of filmstrip frames", "10").action(async (o) => {
953
- const jobDir = resolvePath(o.jobDir);
954
- const video = o.video === "vertical_src" ? join5(jobDir, "vertical_src.mp4") : join5(jobDir, "preview.mp4");
955
- if (!existsSync3(video)) throw new Error(`video not found: ${video}`);
956
- let start = num(o.start);
957
- let end = num(o.end);
958
- let label = "win";
959
- if (o.join !== void 0) {
960
- const edl = readEdl(jobDir);
961
- if (!edl) throw new Error("edl.json required for --join");
962
- const b = cutBoundaries(edl.ranges ?? [])[Number(o.join)];
963
- if (!b) throw new Error(`no join index ${o.join}`);
964
- const dur = await mediaDuration(video);
965
- start = Math.max(0, b.output_time_s - 1.25);
966
- end = Math.min(Math.max(0.05, dur - 0.2), b.output_time_s + 1.25);
967
- label = `join_${String(Number(o.join)).padStart(2, "0")}`;
968
- }
969
- if (start === void 0 || end === void 0) throw new Error("provide --join or --start/--end");
970
- const qc = join5(jobDir, "qc");
971
- mkdirSync4(qc, { recursive: true });
972
- const output = o.output ?? join5(qc, `inspect_${label}_${start.toFixed(2)}-${end.toFixed(2)}.png`);
973
- const transcript = o.transcript ?? (o.video === "vertical_src" ? join5(jobDir, "transcripts", `${loadJob(jobDir).stem}.json`) : void 0);
974
- await timelineView(video, start, end, output, {
975
- nFrames: num(o.nFrames),
976
- transcript: transcript && existsSync3(transcript) ? transcript : void 0
977
- });
978
- console.log(output);
979
214
  });
980
- var timelineCmd = program.command("timeline").description("TimelineV2 authoring: validate / compile / resolve phrases / captions");
981
- function printTimelineErrors(errors, json) {
982
- if (json) {
983
- console.log(JSON.stringify({ ok: false, errors, warnings: [] }, null, 2));
984
- } else {
985
- for (const e of errors) console.log(` [error] ${e.path}: ${e.message}`);
986
- }
987
- }
988
- timelineCmd.command("validate").description("Schema + semantic + capability validation of <job>/timeline.json").requiredOption("--job-dir <path>", "Job directory containing timeline.json").option("--json", "Print the JSON verdict").action(async (o) => {
989
- const jobDir = resolvePath(o.jobDir);
990
- const loaded = loadTimeline(jobDir);
215
+ async function runRemoteCompile(o) {
216
+ const jobDir = jobDirOf(o);
217
+ requireFfmpeg();
218
+ const client = new CloudClient(jobConfig(o, jobDir));
219
+ const loaded = loadTimeline2(jobDir);
991
220
  if (!loaded.doc) {
992
221
  printTimelineErrors(loaded.errors, !!o.json);
993
222
  process.exitCode = 2;
994
223
  return;
995
224
  }
996
- const sem = validateTimeline(loaded.doc);
997
- const uses = usesFromDoc(loaded.doc);
998
- const cap = checkCapabilities(uses);
999
- const errors = [...sem.errors, ...cap.errors];
1000
- const warnings = [...sem.warnings, ...cap.warnings];
1001
- const ok = errors.length === 0;
1002
- if (o.json) {
1003
- console.log(JSON.stringify({ ok, uses, errors, warnings }, null, 2));
1004
- } else {
1005
- console.log(`timeline: ${timelineJsonPath(jobDir)}`);
1006
- console.log(`valid: ${ok ? "yes" : "NO"} (${errors.length} error(s), ${warnings.length} warning(s))`);
1007
- console.log(`uses: ${uses.join(", ") || "(none)"}`);
1008
- for (const e of errors) console.log(` [error] ${e.path}: ${e.message}`);
1009
- for (const w of warnings) console.log(` [warn] ${w.path}: ${w.message}`);
1010
- }
1011
- if (!ok) process.exitCode = 2;
1012
- });
1013
- timelineCmd.command("compile").description("Compile timeline.json -> resolved/plan.json + diagnostics + legacy edl.json").requiredOption("--job-dir <path>", "Job directory containing timeline.json").option("--json", "Print the JSON result").action(async (o) => {
1014
- const jobDir = resolvePath(o.jobDir);
1015
- const loaded = loadTimeline(jobDir);
1016
- if (!loaded.doc) {
1017
- printTimelineErrors(loaded.errors, !!o.json);
225
+ const v = validateLocal(loaded.doc);
226
+ if (!v.ok) {
227
+ printTimelineErrors(v.errors, !!o.json);
1018
228
  process.exitCode = 2;
1019
229
  return;
1020
230
  }
1021
- const ctx = await gatherCompileCtx(jobDir, loaded.doc);
1022
- const result = compileTimeline(loaded.doc, ctx);
1023
- const written = writeCompileArtifacts(jobDir, result);
231
+ const res = await compileRemote(client, jobDir, loaded);
232
+ const diagnostics = res.response.diagnostics;
1024
233
  if (o.json) {
1025
- console.log(
1026
- JSON.stringify(
1027
- {
1028
- ok: result.ok,
1029
- plan: written.plan ?? null,
1030
- edl: written.edl ?? null,
1031
- diagnostics: result.diagnostics,
1032
- uses: result.uses,
1033
- outputDurationS: result.plan?.outputDurationS ?? null,
1034
- segments: result.plan?.segments.length ?? 0,
1035
- events: result.plan?.events.length ?? 0
1036
- },
1037
- null,
1038
- 2
1039
- )
1040
- );
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));
1041
235
  } else {
1042
- if (result.ok && result.plan) {
1043
- console.log(`plan: ${written.plan}`);
1044
- console.log(`edl: ${written.edl}`);
1045
- console.log(
1046
- `output: ${result.plan.outputDurationS.toFixed(2)}s, ${result.plan.segments.length} segment(s), ${result.plan.events.length} event(s)`
1047
- );
1048
- console.log(`uses: ${result.uses.join(", ") || "(none)"}`);
236
+ if (res.ok && res.planPath) {
237
+ console.log(`plan: ${res.planPath}`);
238
+ if (res.edlPath) console.log(`edl: ${res.edlPath}`);
239
+ if (Array.isArray(res.response.uses)) console.log(`uses: ${res.response.uses.join(", ") || "(none)"}`);
240
+ }
241
+ console.log(`diagnostics: ${res.diagnosticsPath}`);
242
+ for (const e of diagnostics?.errors ?? []) {
243
+ const d = e;
244
+ console.log(` [error] ${d.path ?? ""}: ${d.message ?? ""}`);
1049
245
  }
1050
- console.log(`diagnostics: ${written.diagnostics}`);
1051
- for (const line of formatDiagnostics(result.diagnostics)) console.log(line);
1052
246
  }
1053
- if (!result.ok) {
1054
- const ambiguous = result.diagnostics.errors.some((e) => e.code === "anchor-ambiguous");
247
+ if (!res.ok) {
248
+ const ambiguous = (diagnostics?.errors ?? []).some((e) => e.code === "anchor-ambiguous");
1055
249
  process.exitCode = ambiguous ? 3 : 2;
1056
250
  }
251
+ }
252
+ addCloudOptions(
253
+ timelineCmd.command("compile").description("Compile timeline.json on the server \u2192 resolved/plan.json + diagnostics + legacy edl.json (written verbatim)").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
254
+ ).action(async (o) => {
255
+ await withCloudErrors(!!o.json, () => runRemoteCompile(o));
256
+ });
257
+ var captionsCmd = timelineCmd.command("captions").description("Caption authoring helpers");
258
+ addCloudOptions(
259
+ captionsCmd.command("generate").description("Enable captions in timeline.json (preset only), then server-compile to derive/report cues").option("--job-dir <path>", "Job directory (default: cwd)").option("--preset <name>", "Caption preset: grouped (default) | opus-karaoke", "grouped").option("--json", "Print JSON")
260
+ ).action(async (o) => {
261
+ await withCloudErrors(!!o.json, async () => {
262
+ const jobDir = jobDirOf(o);
263
+ const tlPath = timelineJsonPath2(jobDir);
264
+ if (!existsSync(tlPath)) throw new Error(`timeline not found: ${tlPath}`);
265
+ const raw = JSON.parse(readFileSync(tlPath, "utf8"));
266
+ const captions = raw.captions !== null && typeof raw.captions === "object" ? raw.captions : {};
267
+ captions.enabled = true;
268
+ captions.preset = o.preset;
269
+ raw.captions = captions;
270
+ writeFileSync(tlPath, JSON.stringify(raw, null, 2) + "\n", "utf8");
271
+ if (!o.json) console.log(`captions: enabled (preset ${o.preset}) in ${tlPath}`);
272
+ await runRemoteCompile(o);
273
+ });
1057
274
  });
1058
- timelineCmd.command("resolve").description("Resolve a transcript phrase to source seconds (exact, normalized match)").requiredOption("--job-dir <path>", "Job directory").requiredOption("--phrase <text>", "Phrase to locate in the word-level transcript").option("--near <srcSeconds>", "Disambiguate: pick the occurrence nearest this source time").option("--occurrence <n>", "Pick the n-th occurrence (1-based)").option("--source <name>", "Source name (default: the timeline's single source, or the job stem)").option("--json", "Print JSON").action(async (o) => {
1059
- const jobDir = resolvePath(o.jobDir);
1060
- let sourceName = o.source;
1061
- if (!sourceName) {
1062
- const loaded = loadTimeline(jobDir);
1063
- if (loaded.doc) {
1064
- const names = Object.keys(loaded.doc.sources);
1065
- if (names.length !== 1) throw new Error(`timeline has ${names.length} sources (${names.join(", ")}); pass --source`);
1066
- sourceName = names[0];
275
+ 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")
277
+ ).action(async (o) => {
278
+ await withCloudErrors(!!o.json, async () => {
279
+ requireFfmpeg();
280
+ const jobDir = jobDirOf(o);
281
+ const client = new CloudClient(jobConfig(o, jobDir));
282
+ const res = await verifyCloud(client, jobDir, {
283
+ full: !!o.full,
284
+ windowS: num(o.window),
285
+ pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
286
+ verbose: !o.json
287
+ });
288
+ if (o.json) {
289
+ console.log(JSON.stringify({ ok: true, run_id: res.runId, verify: res.verifyPath, verdict: res.verdict, findings: res.findings }, null, 2));
1067
290
  } else {
1068
- sourceName = loadJob(jobDir).stem;
291
+ console.log(`verify: ${res.verdict ?? "(no verdict)"}${res.findings !== null ? ` (${res.findings} finding(s))` : ""}`);
292
+ console.log(`verify.json: ${res.verifyPath}`);
1069
293
  }
1070
- }
1071
- const tfile = transcriptFileForSource(jobDir, sourceName);
1072
- if (!tfile) throw new Error(`no transcript found for source "${sourceName}" in ${join5(jobDir, "transcripts")}`);
1073
- const transcript = JSON.parse(readFileSync2(tfile, "utf8"));
1074
- const stream = wordStream(transcript);
1075
- const outcome = resolvePhrase(stream, o.phrase, { occurrence: num(o.occurrence), nearS: num(o.near) });
1076
- if (outcome.status === "ok") {
1077
- const m = outcome.match;
1078
- const payload2 = { start: m.start, end: m.end, words: m.words, occurrence: m.occurrence };
1079
- if (o.json) console.log(JSON.stringify(payload2, null, 2));
1080
- else {
1081
- console.log(`resolved: ${m.start}s - ${m.end}s (occurrence ${m.occurrence} of ${outcome.matches.length})`);
1082
- console.log(`context: ${m.context}`);
294
+ if (res.verdict === "needs-fixes") process.exitCode = 2;
295
+ });
296
+ });
297
+ 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")
299
+ ).action(async (o) => {
300
+ await withCloudErrors(!!o.json, async () => {
301
+ requireFfmpeg();
302
+ const jobDir = jobDirOf(o);
303
+ 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;
1083
317
  }
1084
- return;
1085
- }
1086
- if (outcome.status === "ambiguous") {
1087
- const payload2 = {
1088
- candidates: outcome.candidates.map((c) => ({
1089
- occurrence: c.occurrence,
1090
- start: c.start,
1091
- end: c.end,
1092
- context: c.context
1093
- }))
1094
- };
1095
- if (o.json) console.log(JSON.stringify(payload2, null, 2));
1096
- else {
1097
- console.log(`ambiguous: ${outcome.candidates.length} occurrence(s); pass --occurrence or --near`);
1098
- for (const c of payload2.candidates) console.log(` #${c.occurrence} ${c.start}s - ${c.end}s: ${c.context}`);
318
+ 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));
320
+ } else {
321
+ console.log(`composition: ${res.compositionDir}`);
322
+ console.log(`entry: ${res.entry}`);
323
+ if (res.out) console.log(`out: ${res.out}`);
1099
324
  }
1100
- process.exitCode = 3;
1101
- return;
1102
- }
1103
- const payload = { error: `phrase not found: ${o.phrase}`, suggestions: outcome.suggestions };
1104
- if (o.json) console.log(JSON.stringify(payload, null, 2));
1105
- else {
1106
- console.log(`not found: "${o.phrase}"`);
1107
- for (const s of outcome.suggestions) console.log(` did you mean "${s.text}" @${s.start}s (similarity ${s.similarity})`);
1108
- }
1109
- process.exitCode = 1;
325
+ });
1110
326
  });
1111
- var captionsCmd = timelineCmd.command("captions").description("Caption authoring helpers");
1112
- captionsCmd.command("generate").description("Enable captions in timeline.json (preset only \u2014 cues are derived at compile)").requiredOption("--job-dir <path>", "Job directory containing timeline.json").option("--preset <name>", "Caption preset: grouped (default, ~4 words/cue) | opus-karaoke (word-by-word)", "grouped").option("--json", "Print JSON").action(async (o) => {
1113
- const jobDir = resolvePath(o.jobDir);
1114
- updateCaptionsBlock(jobDir, o.preset);
1115
- const loaded = loadTimeline(jobDir);
1116
- if (!loaded.doc) {
1117
- printTimelineErrors(loaded.errors, !!o.json);
1118
- process.exitCode = 2;
1119
- return;
1120
- }
1121
- const ctx = await gatherCompileCtx(jobDir, loaded.doc, { noDetect: true });
1122
- const result = compileTimeline(loaded.doc, ctx);
1123
- const cueCount = result.plan?.events.filter((e) => e.kind === "caption-cue").length ?? 0;
1124
- if (o.json) {
1125
- console.log(
1126
- JSON.stringify(
1127
- { ok: result.ok, timeline: timelineJsonPath(jobDir), preset: o.preset, cueCount, diagnostics: result.diagnostics },
1128
- null,
1129
- 2
1130
- )
1131
- );
1132
- } else {
1133
- console.log(`captions: enabled (preset ${o.preset}) in ${timelineJsonPath(jobDir)}`);
1134
- console.log(`derived cues (dry compile): ${cueCount}`);
1135
- for (const line of formatDiagnostics(result.diagnostics)) console.log(line);
1136
- }
1137
- if (!result.ok) process.exitCode = 2;
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
+ 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")
335
+ ).action(async (o) => {
336
+ await withCloudErrors(!!o.json, async () => {
337
+ requireFfmpeg();
338
+ 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
+ });
1138
353
  });
1139
- program.command("compose").description(
1140
- "Build a local HyperFrames composition from resolved/plan.json (cut + captions/zooms/overlays); with --render, render it via the `hyperframes` CLI"
1141
- ).requiredOption("--job-dir <path>", "Job directory containing resolved/plan.json").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>/composed.mp4)").option("--proxy-crf <crf>", "libx264 CRF for the compressed video proxy", "23").option("--json", "Print JSON").action(async (o) => {
1142
- const jobDir = resolvePath(o.jobDir);
1143
- const planFile = planPath(jobDir);
1144
- if (!existsSync3(planFile)) {
1145
- throw new Error(
1146
- `plan not found: ${planFile} \u2014 run \`video-editing timeline compile --job-dir "${jobDir}"\` first`
1147
- );
1148
- }
1149
- const plan = JSON.parse(readFileSync2(planFile, "utf8"));
1150
- const quality = String(o.quality);
1151
- if (!["draft", "standard", "high"].includes(quality)) throw new Error("--quality must be draft|standard|high");
1152
- const fps = num(o.fps) ?? 30;
1153
- const proxyCrf = num(o.proxyCrf) ?? 23;
1154
- const verticalSrc = join5(jobDir, "vertical_src.mp4");
1155
- if (!existsSync3(verticalSrc)) {
1156
- throw new Error(
1157
- `vertical master not found: ${verticalSrc} \u2014 bake it first (e.g. \`video-editing cut-bad-retakes --job-dir "${jobDir}" --render none\`)`
1158
- );
1159
- }
1160
- const preview = join5(jobDir, "preview.mp4");
1161
- if (!existsSync3(preview)) {
1162
- throw new Error(
1163
- `preview not found: ${preview} \u2014 render the QC preview first (\`video-editing render --job-dir "${jobDir}" --mode preview\`)`
1164
- );
1165
- }
1166
- const compDir = join5(jobDir, "composition");
1167
- const mediaDir = join5(compDir, "media");
1168
- mkdirSync4(mediaDir, { recursive: true });
1169
- const stale = (out2, src) => !existsSync3(out2) || statSync3(out2).mtimeMs < statSync3(src).mtimeMs;
1170
- const proxy = join5(mediaDir, "source.mp4");
1171
- if (stale(proxy, verticalSrc)) {
1172
- await run([
1173
- "ffmpeg",
1174
- "-y",
1175
- "-hide_banner",
1176
- "-loglevel",
1177
- "error",
1178
- "-stats",
1179
- "-i",
1180
- verticalSrc,
1181
- "-c:v",
1182
- "libx264",
1183
- "-preset",
1184
- "veryfast",
1185
- "-crf",
1186
- String(proxyCrf),
1187
- "-maxrate",
1188
- "6M",
1189
- "-bufsize",
1190
- "12M",
1191
- "-c:a",
1192
- "aac",
1193
- "-b:a",
1194
- "160k",
1195
- "-movflags",
1196
- "+faststart",
1197
- proxy
1198
- ]);
1199
- } else {
1200
- process.stderr.write(" proxy up to date; skipping re-encode\n");
1201
- }
1202
- const audio = join5(mediaDir, "audio.m4a");
1203
- if (stale(audio, preview)) {
1204
- await run([
1205
- "ffmpeg",
1206
- "-y",
1207
- "-hide_banner",
1208
- "-loglevel",
1209
- "error",
1210
- "-stats",
1211
- "-i",
1212
- preview,
1213
- "-vn",
1214
- "-c:a",
1215
- "aac",
1216
- "-b:a",
1217
- "160k",
1218
- audio
1219
- ]);
1220
- } else {
1221
- process.stderr.write(" cut audio up to date; skipping re-extract\n");
1222
- }
1223
- const assetRefs = /* @__PURE__ */ new Set();
1224
- for (const ev of plan.events ?? []) {
1225
- if (ev.kind === "overlay" && (ev.overlay.kind === "image" || ev.overlay.kind === "sticker") && typeof ev.overlay.asset?.ref === "string") {
1226
- assetRefs.add(ev.overlay.asset.ref);
354
+ 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")
356
+ ).action(async (o) => {
357
+ await withCloudErrors(!!o.json, async () => {
358
+ requireFfmpeg();
359
+ const jobDir = jobDirOf(o);
360
+ jobConfig(o, jobDir, { requireVideoId: false });
361
+ const start = num(o.start);
362
+ const end = num(o.end);
363
+ 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));
372
+ else {
373
+ console.log(`waveform: ${res.png}`);
374
+ if (res.wordsPath) console.log(`words: ${res.wordsPath} (${res.words} word(s))`);
1227
375
  }
1228
- }
1229
- for (const ref of assetRefs) {
1230
- const srcPath = resolvePath(jobDir, ref);
1231
- if (!existsSync3(srcPath)) {
1232
- throw new Error(
1233
- `asset not found: ${ref} (expected at ${srcPath}) \u2014 asset refs are job-dir-relative paths`
1234
- );
376
+ });
377
+ });
378
+ var filesCmd = program.command("files").description("Sync the video's virtual file system with the job dir (byte-exact, sha256-skipped)");
379
+ function registerFilesPull(cmd, deprecated) {
380
+ addCloudOptions(
381
+ cmd.description(
382
+ deprecated ? "[deprecated: use `files pull`] Fetch cloud files into the job dir" : "Fetch cloud files into the job dir, preserving relative paths (writes exactly the served bytes)"
383
+ ).option("--job-dir <path>", "Job directory (default: cwd)").option("--only <paths>", "Comma-separated subset of file paths").option("--json", "Print JSON")
384
+ ).action(async (o) => {
385
+ await withCloudErrors(!!o.json, async () => {
386
+ if (deprecated) process.stderr.write("warning: `job pull` is deprecated; use `files pull`\n");
387
+ const jobDir = jobDirOf(o);
388
+ const client = new CloudClient(jobConfig(o, jobDir));
389
+ const only = typeof o.only === "string" && o.only.trim() !== "" ? o.only.split(",").map((s) => s.trim()).filter((s) => s !== "") : void 0;
390
+ const res = await pullFiles(client, jobDir, { only });
391
+ if (o.json) {
392
+ console.log(JSON.stringify({ ok: true, job_dir: jobDir, pulled: res.pulled.map((p) => ({ path: p.path, local: p.localPath, bytes: p.bytes, skipped: p.skipped })), missing: res.missing }, null, 2));
393
+ } else {
394
+ console.log(`job: ${jobDir}`);
395
+ for (const p of res.pulled) console.log(` ${p.path} \u2192 ${p.localPath} (${p.bytes} bytes${p.skipped ? ", unchanged" : ""})`);
396
+ if (res.pulled.length === 0) console.log(" (no files pulled)");
397
+ if (res.missing.length > 0) console.log(` missing (requested, not on server): ${res.missing.join(", ")}`);
398
+ }
399
+ });
400
+ });
401
+ }
402
+ registerFilesPull(filesCmd.command("pull"), false);
403
+ registerFilesPull(program.command("job", { hidden: true }).command("pull"), true);
404
+ addCloudOptions(
405
+ filesCmd.command("push").description("Push local files (jobDir-relative paths) into the VFS; unchanged files are skipped by sha256").argument("<paths...>", "jobDir-relative file paths to push").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
406
+ ).action(async (paths, o) => {
407
+ await withCloudErrors(!!o.json, async () => {
408
+ const jobDir = jobDirOf(o);
409
+ const client = new CloudClient(jobConfig(o, jobDir));
410
+ const res = await pushFiles(client, jobDir, paths);
411
+ if (o.json) {
412
+ console.log(JSON.stringify({ ok: true, pushed: res.map((p) => ({ path: p.path, bytes: p.bytes, mode: p.mode, skipped: p.skipped })) }, null, 2));
413
+ } else {
414
+ for (const p of res) console.log(` ${p.path} (${p.bytes} bytes, ${p.mode})`);
1235
415
  }
1236
- const dest = join5(mediaDir, ref);
1237
- mkdirSync4(dirname4(dest), { recursive: true });
1238
- copyFileSync(srcPath, dest);
1239
- }
1240
- const tlForGrade = loadTimeline(jobDir);
1241
- const grade = tlForGrade.doc && typeof tlForGrade.doc.settings.grade === "object" ? tlForGrade.doc.settings.grade : void 0;
1242
- const entry = join5(compDir, COMPOSE_ENTRY);
1243
- writeFileSync4(entry, composeHtml(plan, grade !== void 0 ? { grade } : {}), "utf8");
1244
- const counts = composeCounts(plan);
1245
- const out = resolvePath(o.out ?? join5(jobDir, "composed.mp4"));
1246
- const renderCmd = ["hyperframes", "render", "--quality", quality, "--fps", String(fps), "--output", out];
1247
- if (!o.render) {
416
+ });
417
+ });
418
+ var reviewCmd = program.command("review").description("Review rounds: session sync + progress/terminal pushes (plans compiled server-side)");
419
+ addCloudOptions(
420
+ reviewCmd.command("pull").description("Pull the review session: round + instruction into <job>/review, timeline.json when absent").option("--job-dir <path>", "Job directory (default: cwd)").option("--source", "Also download the source video when absent locally").option("--json", "Print JSON")
421
+ ).action(async (o) => {
422
+ await withCloudErrors(!!o.json, async () => {
423
+ const jobDir = jobDirOf(o);
424
+ const client = new CloudClient(jobConfig(o, jobDir));
425
+ const res = await pullReview2(client, jobDir, { withSource: !!o.source });
1248
426
  if (o.json) {
1249
- console.log(JSON.stringify({ compositionDir: compDir, entry, ...counts }, null, 2));
427
+ console.log(JSON.stringify({
428
+ ok: true,
429
+ job_dir: jobDir,
430
+ round: res.round ? { round_id: res.round.roundId, run_id: res.round.runId, seq: res.round.seq, mode: res.round.mode, status: res.round.status, changes: res.round.changes.length } : null,
431
+ wrote_timeline: res.wroteTimeline,
432
+ downloaded_source: res.downloadedSource
433
+ }, null, 2));
1250
434
  } else {
1251
- console.log(`composition: ${compDir}`);
1252
- console.log(`entry: ${entry}`);
1253
- console.log(`segments: ${counts.segments}, cues: ${counts.cues}, zooms: ${counts.zooms}, overlays: ${counts.overlays}`);
1254
- console.log(`render locally: cd ${shown([compDir])} && ${shown(renderCmd)}`);
435
+ console.log(`job: ${jobDir}`);
436
+ if (res.round) {
437
+ console.log(`round: ${res.round.roundId} (run ${res.round.runId}, seq ${res.round.seq}, ${res.round.mode}, ${res.round.status})`);
438
+ console.log(`instruction: ${instructionPath2(jobDir)}`);
439
+ console.log(`changes: ${res.round.changes.length}`);
440
+ } else {
441
+ console.log("no active review round");
442
+ }
443
+ console.log(`timeline.json: ${res.wroteTimeline ? "written" : existsSync(timelineJsonPath2(jobDir)) ? "kept (already present locally)" : "not provided"}`);
444
+ if (res.downloadedSource) console.log(`source: ${res.downloadedSource}`);
1255
445
  }
1256
- return;
1257
- }
1258
- const probe = runSync(["hyperframes", "--version"], { check: false, echo: false });
1259
- if (probe.code !== 0) {
1260
- throw new Error("`hyperframes` CLI not found on PATH \u2014 install it with `npm install -g hyperframes`");
1261
- }
1262
- const res = await run(renderCmd, { cwd: compDir, check: false });
1263
- if (res.code !== 0) {
1264
- process.exitCode = res.code;
1265
- process.stderr.write(`hyperframes render failed with exit ${res.code}
1266
- `);
1267
- return;
1268
- }
1269
- if (o.json) {
1270
- console.log(JSON.stringify({ compositionDir: compDir, entry, out, ...counts }, null, 2));
1271
- } else {
1272
- console.log(`composition: ${compDir}`);
1273
- console.log(`out: ${out}`);
1274
- }
446
+ });
1275
447
  });
1276
- function cloudFlags(o) {
1277
- return { apiBase: o.apiBase, apiKey: o.apiKey, videoId: o.videoId };
1278
- }
1279
- function addCloudOptions(cmd) {
1280
- return cmd.option("--api-base <url>", "API base URL (else CLIPREADY_API_BASE / API_BASE)").option("--api-key <key>", "API credential (else CLIPREADY_API_KEY / RUN_TOKEN)").option("--video-id <id>", "Video id (else VIDEO_ID)");
448
+ function printPush2Failure(res, json) {
449
+ if (json) console.log(JSON.stringify({ ok: false, errors: res.errors, diagnostics: res.diagnostics }, null, 2));
450
+ else for (const e of res.errors) console.log(` [error] ${e.path}: ${e.message}`);
451
+ process.exitCode = 2;
1281
452
  }
1282
- async function withCloudErrors(json, fn) {
1283
- try {
1284
- await fn();
1285
- } catch (err) {
1286
- if (json) {
1287
- const message = err instanceof Error ? err.message : String(err);
1288
- console.log(JSON.stringify({ ok: false, error: message }, null, 2));
1289
- process.exitCode = 1;
453
+ addCloudOptions(
454
+ reviewCmd.command("push").description("Push review-round progress: a --stage event, a live --timeline update, or terminal --complete (compiles on the server)").option("--job-dir <path>", "Job directory (default: cwd)").option("--run-id <id>", "Run id (default: run_id from <job>/review/round.json)").option("--stage <type>", `Progress event type: ${REVIEW_STAGES2.join("|")}`).option("--message <text>", "Optional message payload for --stage").option("--timeline", "Server-compile timeline.json and push a timeline_update").option("--complete", "Server-compile fresh and push the terminal completed event").option("--duration <seconds>", "Total kept duration for --complete (required with --complete)").option("--outcomes <file>", "Per-change outcomes JSON for --complete ([{change_id, ok, note?}])").option("--json", "Print JSON")
455
+ ).action(async (o) => {
456
+ await withCloudErrors(!!o.json, async () => {
457
+ const jobDir = jobDirOf(o);
458
+ const modeCount = [o.stage !== void 0, !!o.timeline, !!o.complete].filter(Boolean).length;
459
+ if (modeCount !== 1) throw new Error("pass exactly one of --stage <type>, --timeline, or --complete");
460
+ const client = new CloudClient(jobConfig(o, jobDir));
461
+ const runId = resolveRunId2(jobDir, o.runId);
462
+ if (o.stage !== void 0) {
463
+ await pushStage2(client, runId, o.stage, o.message);
464
+ if (o.json) console.log(JSON.stringify({ ok: true, run_id: runId, type: o.stage }, null, 2));
465
+ else console.log(`pushed: ${o.stage} (run ${runId})`);
1290
466
  return;
1291
467
  }
1292
- throw err;
1293
- }
1294
- }
468
+ requireFfmpeg();
469
+ if (o.timeline) {
470
+ const res2 = await pushTimeline2(client, jobDir, runId);
471
+ if (!res2.ok) return printPush2Failure(res2, !!o.json);
472
+ if (o.json) console.log(JSON.stringify({ ok: true, run_id: runId, type: "timeline_update", timeline_hash: res2.timelineHash, assets: res2.assets }, null, 2));
473
+ else {
474
+ console.log(`pushed: timeline_update (run ${runId})`);
475
+ if (res2.timelineHash) console.log(`timeline_hash: ${res2.timelineHash}`);
476
+ if (Object.keys(res2.assets).length > 0) console.log(`assets: ${Object.keys(res2.assets).length} synced`);
477
+ }
478
+ return;
479
+ }
480
+ const durationS = num(o.duration);
481
+ if (durationS === void 0) throw new Error("--complete requires --duration <seconds>");
482
+ const res = await pushComplete2(client, jobDir, runId, {
483
+ durationS,
484
+ outcomesPath: o.outcomes ? resolvePath(o.outcomes) : void 0
485
+ });
486
+ if (!res.ok) return printPush2Failure(res, !!o.json);
487
+ if (o.json) console.log(JSON.stringify({ ok: true, run_id: runId, type: "completed", duration_s: res.durationS, outcomes: res.outcomes?.length ?? 0, assets: res.assets }, null, 2));
488
+ else {
489
+ console.log(`pushed: completed (run ${runId})`);
490
+ console.log(`duration_s: ${res.durationS}`);
491
+ if (res.outcomes) console.log(`outcomes: ${res.outcomes.length} change(s)`);
492
+ if (Object.keys(res.assets).length > 0) console.log(`assets: ${Object.keys(res.assets).length} synced`);
493
+ }
494
+ });
495
+ });
496
+ addCloudOptions(
497
+ reviewCmd.command("prompt").description("Print the pulled review-round instruction (run `review pull` first)").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
498
+ ).action(async (o) => {
499
+ await withCloudErrors(!!o.json, async () => {
500
+ const jobDir = jobDirOf(o);
501
+ jobConfig(o, jobDir, { requireVideoId: false });
502
+ 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));
505
+ else {
506
+ console.log(`instruction: ${path}`);
507
+ process.stdout.write(readFileSync(path, "utf8"));
508
+ }
509
+ });
510
+ });
1295
511
  var cloudCmd = program.command("cloud").description("Cloud render + status (clipready render API)");
1296
512
  addCloudOptions(
1297
- cloudCmd.command("render").description("Submit a render; with --wait, poll to completion and download the result").requiredOption("--job-dir <path>", "Job directory (download target)").requiredOption("--mode <mode>", "preview|final").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")
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")
1298
514
  ).action(async (o) => {
1299
515
  await withCloudErrors(!!o.json, async () => {
1300
516
  const mode = o.mode;
1301
517
  if (mode !== "preview" && mode !== "final") throw new Error("--mode must be preview|final");
1302
- const jobDir = resolvePath(o.jobDir);
1303
- const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
518
+ const jobDir = jobDirOf(o);
519
+ const client = new CloudClient(jobConfig(o, jobDir));
1304
520
  const created = await client.createRender(mode);
1305
521
  if (!o.wait) {
1306
522
  if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: created.status }, null, 2));
@@ -1325,9 +541,8 @@ addCloudOptions(
1325
541
  if (!state.videoUrl) throw new Error(`render ${created.renderId} completed without a video_url`);
1326
542
  const out = o.out ? resolvePath(o.out) : defaultRenderOut(jobDir, mode);
1327
543
  const dl = await client.download(state.videoUrl, out);
1328
- if (o.json) {
1329
- 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));
1330
- } else {
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));
545
+ else {
1331
546
  console.log(`render_id: ${created.renderId}`);
1332
547
  console.log(`status: ${state.status}`);
1333
548
  console.log(`out: ${dl.path} (${dl.bytes} bytes)`);
@@ -1340,9 +555,8 @@ addCloudOptions(
1340
555
  await withCloudErrors(!!o.json, async () => {
1341
556
  const client = new CloudClient(resolveConfig(cloudFlags(o)));
1342
557
  const state = await client.getRender(renderId);
1343
- if (o.json) {
1344
- console.log(JSON.stringify({ ok: true, render_id: renderId, status: state.status, video_url: state.videoUrl ?? null, error: state.error ?? null }, null, 2));
1345
- } else {
558
+ if (o.json) console.log(JSON.stringify({ ok: true, render_id: renderId, status: state.status, video_url: state.videoUrl ?? null, error: state.error ?? null }, null, 2));
559
+ else {
1346
560
  console.log(`render_id: ${renderId}`);
1347
561
  console.log(`status: ${state.status}`);
1348
562
  if (state.videoUrl) console.log(`video_url: ${state.videoUrl}`);
@@ -1351,240 +565,81 @@ addCloudOptions(
1351
565
  if (state.status === "failed") process.exitCode = 1;
1352
566
  });
1353
567
  });
1354
- var qaCmd = program.command("qa").description("Cloud QA: server-rendered filmstrip frames + waveform");
1355
568
  addCloudOptions(
1356
- qaCmd.command("frames").description("Render a filmstrip PNG for a window and download it locally").requiredOption("--start <s>", "Window start (seconds)").requiredOption("--end <s>", "Window end (seconds)").option("--cols <n>", "Grid columns").option("--interval <s>", "Seconds between frames").option("--job-dir <path>", "Job directory (QA files go under <job>/qa)").option("--json", "Print JSON")
569
+ program.command("skill").command("install").description("Install the bundled video-editing agent skill").option("--target <dir>", "Skills dir (default ~/.claude/skills)").option("--json", "Print JSON")
1357
570
  ).action(async (o) => {
1358
571
  await withCloudErrors(!!o.json, async () => {
1359
- const start = num(o.start);
1360
- const end = num(o.end);
1361
- if (start === void 0 || end === void 0) throw new Error("--start and --end are required numbers");
1362
- const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
1363
- const res = await client.qaFrames({ start, end, cols: num(o.cols), interval: num(o.interval) });
1364
- const dir = qaDir(o.jobDir ? resolvePath(o.jobDir) : void 0);
1365
- const png = framesPngPath(dir, start, end);
1366
- const dl = await client.download(res.url, png);
1367
- if (o.json) {
1368
- console.log(JSON.stringify({ ok: true, path: dl.path, width: res.width, height: res.height, frames: res.frames, tStart: res.tStart, tEnd: res.tEnd }, null, 2));
1369
- } else {
1370
- console.log(`frames: ${dl.path}`);
1371
- console.log(`grid: ${res.frames} frame(s), ${res.width}x${res.height}, t ${res.tStart}s\u2013${res.tEnd}s`);
1372
- }
1373
- });
1374
- });
1375
- addCloudOptions(
1376
- qaCmd.command("waveform").description("Render a waveform PNG + word list for a window and download both").requiredOption("--start <s>", "Window start (seconds)").requiredOption("--end <s>", "Window end (seconds)").option("--job-dir <path>", "Job directory (QA files go under <job>/qa)").option("--json", "Print JSON")
1377
- ).action(async (o) => {
1378
- await withCloudErrors(!!o.json, async () => {
1379
- const start = num(o.start);
1380
- const end = num(o.end);
1381
- if (start === void 0 || end === void 0) throw new Error("--start and --end are required numbers");
1382
- const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
1383
- const res = await client.qaWaveform({ start, end });
1384
- const dir = qaDir(o.jobDir ? resolvePath(o.jobDir) : void 0);
1385
- const png = waveformPngPath(dir, start, end);
1386
- const wordsPath = waveformWordsPath(dir, start, end);
1387
- const dl = await client.download(res.url, png);
1388
- mkdirSync4(dir, { recursive: true });
1389
- writeFileSync4(wordsPath, JSON.stringify(res.words, null, 2) + "\n", "utf8");
1390
- if (o.json) {
1391
- console.log(JSON.stringify({ ok: true, png: dl.path, words_path: wordsPath, words: res.words.length }, null, 2));
1392
- } else {
1393
- console.log(`waveform: ${dl.path}`);
1394
- console.log(`words: ${wordsPath} (${res.words.length} word(s))`);
1395
- }
1396
- });
1397
- });
1398
- var jobCmd = program.command("job").description("Job artifact sync from the cloud");
1399
- addCloudOptions(
1400
- jobCmd.command("pull").description("Fetch cloud artifacts into the job dir, preserving relative paths").option("--job-dir <path>", "Job directory (default: cwd)").option("--only <names>", "Comma-separated subset of artifact names").option("--json", "Print JSON")
1401
- ).action(async (o) => {
1402
- await withCloudErrors(!!o.json, async () => {
1403
- const jobDir = o.jobDir ? resolvePath(o.jobDir) : process.cwd();
1404
- const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
1405
- const result = await pullArtifacts(client, jobDir, parseOnly(o.only));
1406
- if (o.json) {
1407
- console.log(JSON.stringify({ ok: true, job_dir: jobDir, pulled: result.pulled, missing: result.missing }, null, 2));
1408
- } else {
1409
- console.log(`job: ${jobDir}`);
1410
- for (const a of result.pulled) console.log(` ${a.name} \u2192 ${a.path} (${a.bytes} bytes)`);
1411
- if (result.pulled.length === 0) console.log(" (no artifacts pulled)");
1412
- if (result.missing.length > 0) console.log(` missing (requested, not on server): ${result.missing.join(", ")}`);
1413
- }
1414
- });
1415
- });
1416
- program.command("skill").command("install").description("Install the bundled video-editing agent skill").option("--target <dir>", "Skills dir (default ~/.claude/skills)").option("--json", "Print JSON").action(async (o) => {
1417
- await withCloudErrors(!!o.json, async () => {
572
+ resolveConfig(cloudFlags(o), { requireVideoId: false });
1418
573
  const res = installSkill({ target: o.target });
1419
- if (o.json) {
1420
- console.log(JSON.stringify({ ok: true, source: res.source, dest: res.dest, files: res.files }, null, 2));
1421
- } else {
574
+ if (o.json) console.log(JSON.stringify({ ok: true, source: res.source, dest: res.dest, files: res.files }, null, 2));
575
+ else {
1422
576
  console.log(`installed skill: ${res.dest}`);
1423
577
  console.log(`source: ${res.source}`);
1424
578
  for (const f of res.files) console.log(` ${f}`);
1425
579
  }
1426
580
  });
1427
581
  });
1428
- var reviewCmd = program.command("review").description("Review rounds: local prompt tooling + clipready review-session sync");
1429
- reviewCmd.command("prompt").description("Write the review prompt, warnings, and/or open the review app").requiredOption("--job-dir <path>", "Job directory").option("--write-warnings", "Convert review.json to warnings.json").option("--open-app", "Open the review app at localhost:5180").action(async (o) => {
1430
- const jobDir = resolvePath(o.jobDir);
1431
- const job = loadJob(jobDir);
1432
- const reviewPrompt = writeVideoEditingPrompt(jobDir, job.stem, "review.prompt.md", "review.prompt.md");
1433
- console.log(`review_prompt: ${reviewPrompt}`);
1434
- if (o.writeWarnings) console.log(`warnings: ${writeWarningsFromReview(jobDir)}`);
1435
- if (o.openApp) {
1436
- const url = `http://localhost:5180/?job=${join5(jobDir).split("/").pop()}`;
1437
- await run(["open", url], { check: false, echo: false });
1438
- console.log(url);
1439
- }
1440
- });
1441
- function printPushCompileFailure(res, json) {
1442
- if (json) {
1443
- console.log(JSON.stringify({ ok: false, errors: res.errors, diagnostics: res.diagnostics }, null, 2));
1444
- } else if (res.diagnostics) {
1445
- for (const line of formatDiagnostics(res.diagnostics)) console.log(line);
1446
- } else {
1447
- for (const e of res.errors) console.log(` [error] ${e.path}: ${e.message}`);
1448
- }
1449
- process.exitCode = 2;
1450
- }
582
+ var STATUS_FILES = [
583
+ "vertical_src.mp4",
584
+ "timeline.json",
585
+ "resolved/plan.json",
586
+ "resolved/diagnostics.json",
587
+ "edl.json",
588
+ "preview.mp4",
589
+ "final.mp4",
590
+ "preview.mp4",
591
+ "takes_packed.md",
592
+ "flags.txt",
593
+ "word_dump.txt",
594
+ "coverage.json",
595
+ "verify.json",
596
+ "review/round.json",
597
+ "review/instruction.md"
598
+ ];
1451
599
  addCloudOptions(
1452
- reviewCmd.command("pull").description("Pull the review session: round + instruction into <job>/review, timeline.json when absent").option("--job-dir <path>", "Job directory (default: cwd)").option("--source", "Also download the source video when absent locally").option("--json", "Print JSON")
600
+ program.command("status").description("Print the cloud job's local state (job.json + which artifacts are present)").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
1453
601
  ).action(async (o) => {
1454
602
  await withCloudErrors(!!o.json, async () => {
1455
- const jobDir = o.jobDir ? resolvePath(o.jobDir) : process.cwd();
1456
- const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
1457
- const res = await pullReview(client, jobDir, { withSource: !!o.source });
603
+ const jobDir = jobDirOf(o);
604
+ jobConfig(o, jobDir, { requireVideoId: false });
605
+ const job = readJobFile(jobDir);
606
+ 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));
1458
609
  if (o.json) {
1459
- console.log(
1460
- JSON.stringify(
1461
- {
1462
- ok: true,
1463
- job_dir: jobDir,
1464
- round: res.round ? {
1465
- round_id: res.round.roundId,
1466
- run_id: res.round.runId,
1467
- seq: res.round.seq,
1468
- mode: res.round.mode,
1469
- status: res.round.status,
1470
- changes: res.round.changes.length
1471
- } : null,
1472
- wrote_timeline: res.wroteTimeline,
1473
- downloaded_source: res.downloadedSource
1474
- },
1475
- null,
1476
- 2
1477
- )
1478
- );
610
+ console.log(JSON.stringify({ ok: true, job_dir: jobDir, job: job ?? null, artifacts }, null, 2));
1479
611
  } else {
1480
612
  console.log(`job: ${jobDir}`);
1481
- if (res.round) {
1482
- console.log(`round: ${res.round.roundId} (run ${res.round.runId}, seq ${res.round.seq}, ${res.round.mode}, ${res.round.status})`);
1483
- console.log(`instruction: ${instructionPath(jobDir)}`);
1484
- console.log(`changes: ${res.round.changes.length}`);
1485
- } else {
1486
- console.log("no active review round");
1487
- }
1488
- console.log(`timeline.json: ${res.wroteTimeline ? "written" : existsSync3(timelineJsonPath(jobDir)) ? "kept (already present locally)" : "not provided"}`);
1489
- if (res.downloadedSource) console.log(`source: ${res.downloadedSource}`);
1490
- }
1491
- });
1492
- });
1493
- addCloudOptions(
1494
- reviewCmd.command("push").description("Push review-round progress: a --stage event, a live --timeline update, or terminal --complete").option("--job-dir <path>", "Job directory (default: cwd)").option("--run-id <id>", "Run id (default: run_id from <job>/review/round.json)").option("--stage <type>", `Progress event type: ${REVIEW_STAGES.join("|")}`).option("--message <text>", "Optional message payload for --stage").option("--timeline", "Compile timeline.json and push a timeline_update (review page updates live)").option("--complete", "Compile fresh and push the terminal completed event").option("--duration <seconds>", "Total kept duration for --complete (required with --complete)").option("--outcomes <file>", "Per-change outcomes JSON for --complete ([{change_id, ok, note?}])").option("--json", "Print JSON")
1495
- ).action(async (o) => {
1496
- await withCloudErrors(!!o.json, async () => {
1497
- const jobDir = o.jobDir ? resolvePath(o.jobDir) : process.cwd();
1498
- const modeCount = [o.stage !== void 0, !!o.timeline, !!o.complete].filter(Boolean).length;
1499
- if (modeCount !== 1) {
1500
- throw new Error("pass exactly one of --stage <type>, --timeline, or --complete");
1501
- }
1502
- const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
1503
- const runId = resolveRunId(jobDir, o.runId);
1504
- if (o.stage !== void 0) {
1505
- await pushStage(client, runId, o.stage, o.message);
1506
- if (o.json) console.log(JSON.stringify({ ok: true, run_id: runId, type: o.stage }, null, 2));
1507
- else console.log(`pushed: ${o.stage} (run ${runId})`);
1508
- return;
1509
- }
1510
- if (o.timeline) {
1511
- const res2 = await pushTimeline(client, jobDir, runId);
1512
- if (!res2.ok) {
1513
- printPushCompileFailure(res2, !!o.json);
1514
- return;
1515
- }
1516
- if (o.json) {
1517
- console.log(JSON.stringify({ ok: true, run_id: runId, type: "timeline_update", timeline_hash: res2.timelineHash, output_duration_s: res2.plan.outputDurationS, segments: res2.plan.segments.length, assets: res2.assets }, null, 2));
613
+ if (job) {
614
+ console.log(`video_id: ${job.video_id}`);
615
+ console.log(`project_id: ${job.project_id}`);
616
+ console.log(`stem: ${job.stem} (${job.duration_s}s)`);
1518
617
  } else {
1519
- console.log(`pushed: timeline_update (run ${runId})`);
1520
- console.log(`output: ${res2.plan.outputDurationS.toFixed(2)}s, ${res2.plan.segments.length} segment(s)`);
1521
- console.log(`timeline_hash: ${res2.timelineHash}`);
1522
- if (Object.keys(res2.assets).length > 0) console.log(`assets: ${Object.keys(res2.assets).length} synced`);
618
+ console.log(`job.json: missing (${jobJsonPath(jobDir)}) \u2014 run \`video-editing init\``);
1523
619
  }
1524
- return;
1525
- }
1526
- const durationS = num(o.duration);
1527
- if (durationS === void 0) throw new Error("--complete requires --duration <seconds>");
1528
- const res = await pushComplete(client, jobDir, runId, {
1529
- durationS,
1530
- outcomesPath: o.outcomes ? resolvePath(o.outcomes) : void 0
1531
- });
1532
- if (!res.ok) {
1533
- printPushCompileFailure(res, !!o.json);
1534
- return;
1535
- }
1536
- if (o.json) {
1537
- console.log(JSON.stringify({ ok: true, run_id: runId, type: "completed", duration_s: res.durationS, outcomes: res.outcomes?.length ?? 0, assets: res.assets }, null, 2));
1538
- } else {
1539
- console.log(`pushed: completed (run ${runId})`);
1540
- console.log(`duration_s: ${res.durationS}`);
1541
- if (res.outcomes) console.log(`outcomes: ${res.outcomes.length} change(s)`);
1542
- if (Object.keys(res.assets).length > 0) console.log(`assets: ${Object.keys(res.assets).length} synced`);
620
+ console.log("artifacts:");
621
+ for (const [name, ok] of Object.entries(artifacts)) console.log(` ${name}: ${ok ? "ok" : "missing"}`);
1543
622
  }
1544
623
  });
1545
624
  });
1546
- program.command("status").description("Print job status and artifact paths").requiredOption("--job-dir <path>", "Job directory").option("--json", "Print JSON").action(async (o) => {
1547
- const jobDir = resolvePath(o.jobDir);
1548
- const payload = await statusPayload(jobDir);
1549
- if (o.json) {
1550
- console.log(JSON.stringify(payload, null, 2));
1551
- return;
1552
- }
1553
- console.log(`job: ${payload.job_dir}`);
1554
- console.log(`source: ${payload.source}`);
1555
- const tr = payload.transcript;
1556
- if (tr) console.log(`transcript: ${tr.word_count} words, coverage gap ${tr.coverage_gap_s.toFixed(2)}s`);
1557
- const edl = payload.edl;
1558
- if (edl) console.log(`edl: ${edl.range_count} ranges, ${edl.total_duration_s}s`);
1559
- const prev = payload.preview;
1560
- if (prev) console.log(`preview: ${prev.width}x${prev.height}, ${prev.duration_s.toFixed(2)}s`);
1561
- const rev = payload.review;
1562
- if (rev) console.log(`review: ${rev.verdict} (${rev.issue_count} issues)`);
1563
- const ver = payload.verify;
1564
- if (ver) console.log(`verify: ${ver.verdict} (${ver.finding_count} findings)`);
1565
- console.log(`qc pngs: ${payload.qc_png_count}`);
1566
- console.log("artifacts:");
1567
- for (const [name, path] of Object.entries(artifactPaths(jobDir, loadJob(jobDir).stem))) {
1568
- console.log(` ${name}: ${existsSync3(path) ? "ok" : "missing"} ${path}`);
1569
- }
1570
- });
1571
- program.command("config").description("Manage CLI config (ElevenLabs API key)").argument("<action>", "set-key | path").argument("[value]", "key value for set-key").action((action, value) => {
625
+ 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) => {
1572
626
  if (action === "path") {
1573
- console.log(configEnvPath());
627
+ console.log(publicConfigEnvPath());
1574
628
  return;
1575
629
  }
1576
630
  if (action === "set-key") {
1577
631
  if (!value) throw new Error("usage: video-editing config set-key <key>");
1578
- mkdirSync4(configDir(), { recursive: true });
1579
- writeFileSync4(configEnvPath(), `ELEVENLABS_API_KEY=${value}
1580
- `, { mode: 384 });
1581
- console.log(`wrote ${configEnvPath()}`);
632
+ console.log(`wrote ${savePublicConfigKey("CLIPREADY_API_KEY", value)}`);
633
+ return;
634
+ }
635
+ if (action === "set-base") {
636
+ if (!value) throw new Error("usage: video-editing config set-base <url>");
637
+ console.log(`wrote ${savePublicConfigKey("CLIPREADY_API_BASE", value)}`);
1582
638
  return;
1583
639
  }
1584
640
  throw new Error(`unknown config action: ${action}`);
1585
641
  });
1586
642
  async function main() {
1587
- requireFfmpeg();
1588
643
  await program.parseAsync(process.argv);
1589
644
  }
1590
645
  main().catch((err) => {