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/README.md +49 -58
- package/dist/chunk-SSXAUKGV.js +2549 -0
- package/dist/cli.js +498 -1443
- package/dist/index.d.ts +1062 -921
- package/dist/index.js +297 -260
- package/package.json +2 -19
- package/skills/video-editing/SKILL.md +139 -232
- package/skills/video-editing/references/adversarial-review.md +3 -43
- package/skills/video-editing/references/cli-reference.md +124 -159
- package/skills/video-editing/references/editing-brief.md +3 -43
- package/skills/video-editing/references/hard-rules.md +3 -37
- package/skills/video-editing/references/scribe-collapse.md +3 -36
- package/skills/video-editing/references/timeline-v2-vocabulary.md +2 -2
- package/dist/assets/DejaVuSansMono.ttf +0 -0
- package/dist/chunk-63HIQBDT.js +0 -4321
- package/dist/chunk-CU3NBKB4.js +0 -307
- package/dist/job-OFCSLPBV.js +0 -25
- package/dist/templates/author-edl.prompt.md +0 -33
- package/dist/templates/fix.prompt.md +0 -23
- package/dist/templates/review.prompt.md +0 -30
package/dist/cli.js
CHANGED
|
@@ -1,1306 +1,522 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 {
|
|
54
|
-
import {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
192
|
-
return
|
|
46
|
+
function cloudFlags(o) {
|
|
47
|
+
return { apiBase: o.apiBase, apiKey: o.apiKey, videoId: o.videoId };
|
|
193
48
|
}
|
|
194
|
-
function
|
|
195
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
await
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
});
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
|
997
|
-
|
|
998
|
-
|
|
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
|
|
1022
|
-
const
|
|
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 (
|
|
1043
|
-
console.log(`plan: ${
|
|
1044
|
-
console.log(`edl: ${
|
|
1045
|
-
console.log(
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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 (!
|
|
1054
|
-
const 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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
const
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
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
|
|
1112
|
-
|
|
1113
|
-
const
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
console.log(
|
|
1135
|
-
|
|
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
|
-
|
|
1140
|
-
"
|
|
1141
|
-
).
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
);
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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({
|
|
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(`
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
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
|
|
1277
|
-
|
|
1278
|
-
}
|
|
1279
|
-
|
|
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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1303
|
-
const client = new CloudClient(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
-
|
|
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 =
|
|
1456
|
-
|
|
1457
|
-
const
|
|
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 (
|
|
1482
|
-
console.log(`
|
|
1483
|
-
console.log(`
|
|
1484
|
-
console.log(`
|
|
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(`
|
|
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
|
-
|
|
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("
|
|
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(
|
|
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
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
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) => {
|