video-editing 1.3.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -57
- package/dist/chunk-W4IIL2T2.js +2488 -0
- package/dist/cli.js +590 -1435
- package/dist/index.d.ts +1099 -922
- package/dist/index.js +319 -268
- package/package.json +2 -19
- package/skills/video-editing/SKILL.md +170 -235
- package/skills/video-editing/references/adversarial-review.md +3 -43
- package/skills/video-editing/references/cli-reference.md +165 -160
- 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,1541 +1,507 @@
|
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
requireFfmpeg,
|
|
46
|
-
resolveJob,
|
|
47
|
-
run,
|
|
48
|
-
runSync,
|
|
49
|
-
shown
|
|
50
|
-
} from "./chunk-CU3NBKB4.js";
|
|
3
|
+
CloudClient,
|
|
4
|
+
REVIEW_STAGES2,
|
|
5
|
+
compileRemote,
|
|
6
|
+
defaultRenderOut,
|
|
7
|
+
framesPngPath,
|
|
8
|
+
initCloudJob,
|
|
9
|
+
inspectPngPath,
|
|
10
|
+
installSkill,
|
|
11
|
+
instructionPath2,
|
|
12
|
+
jobJsonPath,
|
|
13
|
+
joinSourceWindows,
|
|
14
|
+
joinSpanWindow,
|
|
15
|
+
loadPublicConfigEnv,
|
|
16
|
+
loadTimeline2,
|
|
17
|
+
outWindowToSourceWindows,
|
|
18
|
+
pollRender,
|
|
19
|
+
publicConfigEnvPath,
|
|
20
|
+
pullFiles,
|
|
21
|
+
pullReview2,
|
|
22
|
+
pushComplete2,
|
|
23
|
+
pushFiles,
|
|
24
|
+
pushStage2,
|
|
25
|
+
pushTimeline2,
|
|
26
|
+
qaDir,
|
|
27
|
+
readJobFile,
|
|
28
|
+
readPlanSegments,
|
|
29
|
+
removePublicConfigKey,
|
|
30
|
+
resolveApiBase,
|
|
31
|
+
resolveApiKey,
|
|
32
|
+
resolveConfig,
|
|
33
|
+
resolveRunId2,
|
|
34
|
+
savePublicConfigKey,
|
|
35
|
+
stripTrailingSlash,
|
|
36
|
+
timelineJsonPath2,
|
|
37
|
+
transcribeCloud,
|
|
38
|
+
validateLocal,
|
|
39
|
+
verifyCloud,
|
|
40
|
+
videoIdFromJob,
|
|
41
|
+
waveformPngPath,
|
|
42
|
+
waveformWordsPath,
|
|
43
|
+
webVideoUrl
|
|
44
|
+
} from "./chunk-W4IIL2T2.js";
|
|
51
45
|
|
|
52
|
-
// src/cli.ts
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
46
|
+
// src/public/cli.ts
|
|
47
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
48
|
+
import { join, resolve as resolvePath } from "path";
|
|
55
49
|
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;
|
|
50
|
+
loadPublicConfigEnv();
|
|
51
|
+
function num(v) {
|
|
52
|
+
if (v === void 0) return void 0;
|
|
53
|
+
const n = Number(v);
|
|
54
|
+
return Number.isFinite(n) ? n : void 0;
|
|
190
55
|
}
|
|
191
|
-
function
|
|
192
|
-
return
|
|
56
|
+
function cloudFlags(o) {
|
|
57
|
+
return { apiBase: o.apiBase, apiKey: o.apiKey, videoId: o.videoId };
|
|
193
58
|
}
|
|
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
|
-
};
|
|
59
|
+
function addCloudOptions(cmd) {
|
|
60
|
+
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
61
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
this.name = "CloudHttpError";
|
|
244
|
-
}
|
|
245
|
-
status;
|
|
246
|
-
body;
|
|
247
|
-
};
|
|
248
|
-
function extractErrorMessage(data) {
|
|
249
|
-
if (typeof data === "string" && data.trim() !== "") return data;
|
|
250
|
-
if (data && typeof data === "object") {
|
|
251
|
-
const o = data;
|
|
252
|
-
if (typeof o.error === "string") return o.error;
|
|
253
|
-
if (typeof o.message === "string") return o.message;
|
|
254
|
-
if (o.error) return JSON.stringify(o.error);
|
|
62
|
+
function jobConfig(o, jobDir, opts = { requireVideoId: true }) {
|
|
63
|
+
const flags = cloudFlags(o);
|
|
64
|
+
const job = readJobFile(jobDir);
|
|
65
|
+
if (!flags.apiBase && !process.env.CLIPREADY_API_BASE && !process.env.API_BASE && job?.api_base) {
|
|
66
|
+
flags.apiBase = job.api_base;
|
|
255
67
|
}
|
|
256
|
-
|
|
68
|
+
const cfg = resolveConfig(flags, { requireVideoId: false });
|
|
69
|
+
const videoId = opts.requireVideoId ? videoIdFromJob(jobDir, cfg.videoId) : cfg.videoId;
|
|
70
|
+
return { ...cfg, videoId };
|
|
257
71
|
}
|
|
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);
|
|
72
|
+
async function withCloudErrors(json, fn) {
|
|
404
73
|
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 };
|
|
74
|
+
await fn();
|
|
416
75
|
} catch (err) {
|
|
417
|
-
if (
|
|
418
|
-
|
|
76
|
+
if (json) {
|
|
77
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
78
|
+
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
419
81
|
}
|
|
420
82
|
throw err;
|
|
421
|
-
} finally {
|
|
422
|
-
clearTimeout(timer);
|
|
423
83
|
}
|
|
424
84
|
}
|
|
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");
|
|
85
|
+
function jobDirOf(o) {
|
|
86
|
+
return o.jobDir ? resolvePath(String(o.jobDir)) : process.cwd();
|
|
432
87
|
}
|
|
433
|
-
|
|
434
|
-
|
|
88
|
+
var pkg;
|
|
89
|
+
try {
|
|
90
|
+
pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
pkg = { version: "3.0.0" };
|
|
435
93
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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");
|
|
452
|
-
}
|
|
453
|
-
async function pullReview(client, jobDir, opts = {}) {
|
|
454
|
-
const session = await client.getReview();
|
|
455
|
-
mkdirSync2(reviewDir(jobDir), { recursive: true });
|
|
456
|
-
const round = session.activeRound;
|
|
457
|
-
if (round) {
|
|
458
|
-
const roundFile = {
|
|
459
|
-
video_id: session.videoId,
|
|
460
|
-
round_id: round.roundId,
|
|
461
|
-
run_id: round.runId,
|
|
462
|
-
seq: round.seq,
|
|
463
|
-
mode: round.mode,
|
|
464
|
-
status: round.status,
|
|
465
|
-
pulled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
466
|
-
changes: round.changes
|
|
467
|
-
};
|
|
468
|
-
writeFileSync2(roundJsonPath(jobDir), JSON.stringify(roundFile, null, 2) + "\n", "utf8");
|
|
469
|
-
writeFileSync2(instructionPath(jobDir), round.instruction + "\n", "utf8");
|
|
470
|
-
} else {
|
|
471
|
-
writeFileSync2(instructionPath(jobDir), "No active review round for this video.\n", "utf8");
|
|
472
|
-
}
|
|
473
|
-
let wroteTimeline = false;
|
|
474
|
-
const tlPath = timelineJsonPath(jobDir);
|
|
475
|
-
if (session.timeline !== null && session.timeline !== void 0 && !existsSync(tlPath)) {
|
|
476
|
-
writeFileSync2(tlPath, JSON.stringify(session.timeline, null, 2) + "\n", "utf8");
|
|
477
|
-
wroteTimeline = true;
|
|
478
|
-
}
|
|
479
|
-
let downloadedSource = null;
|
|
480
|
-
if (opts.withSource && session.sourceUrl) {
|
|
481
|
-
const dest = sourceDownloadPath(jobDir);
|
|
482
|
-
if (!existsSync(dest)) {
|
|
483
|
-
const dl = await client.download(session.sourceUrl, dest);
|
|
484
|
-
downloadedSource = dl.path;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
return { jobDir, round, wroteTimeline, downloadedSource };
|
|
488
|
-
}
|
|
489
|
-
function resolveRunId(jobDir, flagOverride) {
|
|
490
|
-
if (typeof flagOverride === "string" && flagOverride.trim() !== "") return flagOverride.trim();
|
|
491
|
-
const path = roundJsonPath(jobDir);
|
|
492
|
-
if (existsSync(path)) {
|
|
94
|
+
var program = new Command();
|
|
95
|
+
program.name("video-editing").description("Pure-API cloud CLI for editing talking-head reels with clipready: init (upload + server ingest/transcribe), timeline authoring, cloud compile/render/verify/QA, file sync, review rounds. No local media tools needed \u2014 just Node \u2265 20 and CLIPREADY_API_KEY.").version(pkg.version);
|
|
96
|
+
addCloudOptions(
|
|
97
|
+
program.command("init").description("Create the cloud job end-to-end: stream the source up, run server-side ingest (probe + transcription + screening), pull the transcript/brief artifacts, write <job>/job.json. Covers what init+transcribe used to do \u2014 no separate transcribe needed.").requiredOption("--source <path>", "Source video path").option("--job-dir <path>", "Job directory (default ./job_<stem>)").option("--title <title>", "Project title (default: source stem)").option("--poll-interval <s>", "Ingest-run poll interval in seconds", "5").option("--json", "Print JSON")
|
|
98
|
+
).action(async (o) => {
|
|
99
|
+
await withCloudErrors(!!o.json, async () => {
|
|
100
|
+
const cfg = resolveConfig(cloudFlags(o), { requireVideoId: false });
|
|
101
|
+
const client = new CloudClient(cfg);
|
|
102
|
+
let res;
|
|
493
103
|
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 };
|
|
104
|
+
res = await initCloudJob(client, {
|
|
105
|
+
source: o.source,
|
|
106
|
+
jobDir: o.jobDir,
|
|
107
|
+
title: o.title,
|
|
108
|
+
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
109
|
+
verbose: !o.json
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err instanceof Error && /\/v1\/me|401|403|invalid.*(key|token)|unauthorized/i.test(err.message)) {
|
|
113
|
+
throw new Error(`${err.message}
|
|
114
|
+
Your credential was rejected \u2014 get your API key at ${cfg.base}/settings and run \`video-editing config set-key <key>\`.`);
|
|
556
115
|
}
|
|
116
|
+
throw err;
|
|
557
117
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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 } : {}
|
|
118
|
+
if (o.json) {
|
|
119
|
+
console.log(JSON.stringify({ ok: true, job_dir: res.jobDir, job_json: res.jobJson, video_id: res.videoId, project_id: res.projectId, stem: res.stem, duration_s: res.durationS, uploaded: res.uploaded, uploaded_bytes: res.uploadedBytes, run_id: res.runId, pulled: res.pulled.pulled.map((p) => p.path), missing: res.pulled.missing, web_url: res.webUrl }, null, 2));
|
|
120
|
+
} else {
|
|
121
|
+
console.log(`job: ${res.jobDir}`);
|
|
122
|
+
console.log(`video_id: ${res.videoId}`);
|
|
123
|
+
console.log(`project_id: ${res.projectId}`);
|
|
124
|
+
console.log(`job.json: ${res.jobJson}`);
|
|
125
|
+
for (const p of res.pulled.pulled) console.log(` ${p.path} \u2192 ${p.localPath}${p.skipped ? " (unchanged)" : ""}`);
|
|
126
|
+
if (res.pulled.missing.length > 0) console.log(` missing on server: ${res.pulled.missing.join(", ")}`);
|
|
127
|
+
console.log(`web: ${res.webUrl}`);
|
|
627
128
|
}
|
|
628
129
|
});
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
130
|
+
});
|
|
131
|
+
addCloudOptions(
|
|
132
|
+
program.command("transcribe").description("Re-run/pull transcription: pull the transcript artifacts when they already exist server-side, else run server-side ingest and pull (init already did this once)").option("--job-dir <path>", "Job directory (default: cwd)").option("--poll-interval <s>", "Run poll interval in seconds", "5").option("--json", "Print JSON")
|
|
133
|
+
).action(async (o) => {
|
|
134
|
+
await withCloudErrors(!!o.json, async () => {
|
|
135
|
+
const jobDir = jobDirOf(o);
|
|
136
|
+
const cfg = jobConfig(o, jobDir);
|
|
137
|
+
const client = new CloudClient(cfg);
|
|
138
|
+
const job = readJobFile(jobDir);
|
|
139
|
+
const res = await transcribeCloud(client, jobDir, {
|
|
140
|
+
stem: job?.stem ?? "source",
|
|
141
|
+
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
142
|
+
verbose: !o.json
|
|
143
|
+
});
|
|
144
|
+
if (o.json) {
|
|
145
|
+
console.log(JSON.stringify({ ok: true, run_id: res.runId, pulled: res.pulled.pulled.map((p) => p.path), missing: res.pulled.missing }, null, 2));
|
|
146
|
+
} else {
|
|
147
|
+
if (res.runId) console.log(`run: ${res.runId}`);
|
|
148
|
+
for (const p of res.pulled.pulled) console.log(` ${p.path} \u2192 ${p.localPath}${p.skipped ? " (unchanged)" : ""}`);
|
|
149
|
+
if (res.pulled.missing.length > 0) console.log(` missing on server: ${res.pulled.missing.join(", ")}`);
|
|
647
150
|
}
|
|
648
|
-
return { change_id: r.change_id, ok: r.ok, ...r.note !== void 0 ? { note: r.note } : {} };
|
|
649
151
|
});
|
|
152
|
+
});
|
|
153
|
+
var timelineCmd = program.command("timeline").description("TimelineV2 authoring: validate locally, resolve/compile on the server");
|
|
154
|
+
function printTimelineErrors(errors, json) {
|
|
155
|
+
if (json) console.log(JSON.stringify({ ok: false, errors, warnings: [] }, null, 2));
|
|
156
|
+
else for (const e of errors) console.log(` [error] ${e.path}: ${e.message}`);
|
|
650
157
|
}
|
|
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 } : {}
|
|
158
|
+
addCloudOptions(
|
|
159
|
+
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")
|
|
160
|
+
).action(async (o) => {
|
|
161
|
+
await withCloudErrors(!!o.json, async () => {
|
|
162
|
+
const jobDir = jobDirOf(o);
|
|
163
|
+
jobConfig(o, jobDir, { requireVideoId: false });
|
|
164
|
+
const loaded = loadTimeline2(jobDir);
|
|
165
|
+
if (!loaded.doc) {
|
|
166
|
+
printTimelineErrors(loaded.errors, !!o.json);
|
|
167
|
+
process.exitCode = 2;
|
|
168
|
+
return;
|
|
666
169
|
}
|
|
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})`);
|
|
170
|
+
const v = validateLocal(loaded.doc);
|
|
171
|
+
if (o.json) {
|
|
172
|
+
console.log(JSON.stringify({ ok: v.ok, uses: v.uses, errors: v.errors, warnings: v.warnings }, null, 2));
|
|
173
|
+
} else {
|
|
174
|
+
console.log(`timeline: ${timelineJsonPath2(jobDir)}`);
|
|
175
|
+
console.log(`valid: ${v.ok ? "yes" : "NO"} (${v.errors.length} error(s), ${v.warnings.length} warning(s))`);
|
|
176
|
+
console.log(`uses: ${v.uses.join(", ") || "(none)"}`);
|
|
177
|
+
for (const e of v.errors) console.log(` [error] ${e.path}: ${e.message}`);
|
|
178
|
+
for (const w of v.warnings) console.log(` [warn] ${w.path}: ${w.message}`);
|
|
691
179
|
}
|
|
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
|
|
180
|
+
if (!v.ok) process.exitCode = 2;
|
|
819
181
|
});
|
|
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
182
|
});
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
})
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
printPrepared(jobDir, prompt, !existsSync3(edlPath(jobDir)));
|
|
870
|
-
});
|
|
871
|
-
program.command("cut-bad-retakes").description("Prepare the bad-retake (author-edl) prompt; render/QC if edl.json exists").option("--source <path>", "Source video path").option("--job-dir <path>", "Existing job directory").option("--with-sound-waves", "Write preview join QC PNGs if preview.mp4 exists").option("--quality-review", "Write review prompt; convert review.json to warnings if present").option("--render <mode>", "Render if edl.json exists (preview|none)", "none").option("--language <code>", "Optional ISO language code").option("--num-speakers <n>", "Optional speaker count").option("--force-transcribe", "Regenerate transcript before preparing").option("--api-key <key>", "ElevenLabs API key override").action(async (o) => {
|
|
872
|
-
const { jobDir, source, stem } = await resolveJob(o);
|
|
873
|
-
await ensureTranscribed(jobDir, source, stem, {
|
|
874
|
-
language: o.language,
|
|
875
|
-
numSpeakers: num(o.numSpeakers),
|
|
876
|
-
force: !!o.forceTranscribe,
|
|
877
|
-
apiKey: o.apiKey
|
|
878
|
-
});
|
|
879
|
-
await ensureVerticalMaster(jobDir, source);
|
|
880
|
-
const prompt = writeVideoEditingPrompt(jobDir, stem, "author-edl.prompt.md", "author-edl.prompt.md");
|
|
881
|
-
writeAgentManifest(jobDir, source, stem, "cut-bad-retakes");
|
|
882
|
-
const preview = await renderJob(jobDir, o.render === "preview" ? "preview" : "none");
|
|
883
|
-
if (preview) console.log(`preview: ${preview}`);
|
|
884
|
-
else if (o.render !== "none") process.stderr.write(`edl not found yet; write ${edlPath(jobDir)} from ${prompt}
|
|
885
|
-
`);
|
|
886
|
-
if (o.withSoundWaves) {
|
|
887
|
-
const qc = await generateQcImages(jobDir, preview ? { preview } : {});
|
|
888
|
-
if (qc) console.log(`qc: ${qc}`);
|
|
889
|
-
else process.stderr.write("qc not generated yet; it requires edl.json and preview.mp4\n");
|
|
890
|
-
}
|
|
891
|
-
if (o.qualityReview) {
|
|
892
|
-
const reviewPrompt = writeVideoEditingPrompt(jobDir, stem, "review.prompt.md", "review.prompt.md");
|
|
893
|
-
console.log(`review_prompt: ${reviewPrompt}`);
|
|
894
|
-
if (existsSync3(join5(jobDir, "review.json"))) {
|
|
895
|
-
console.log(`warnings: ${writeWarningsFromReview(jobDir)}`);
|
|
183
|
+
addCloudOptions(
|
|
184
|
+
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")
|
|
185
|
+
).action(async (o) => {
|
|
186
|
+
await withCloudErrors(!!o.json, async () => {
|
|
187
|
+
const jobDir = jobDirOf(o);
|
|
188
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
189
|
+
const res = await client.resolveRemote({
|
|
190
|
+
phrase: o.phrase,
|
|
191
|
+
...o.source !== void 0 ? { source: o.source } : {},
|
|
192
|
+
...num(o.occurrence) !== void 0 ? { occurrence: num(o.occurrence) } : {}
|
|
193
|
+
});
|
|
194
|
+
if (o.json) {
|
|
195
|
+
console.log(JSON.stringify(res, null, 2));
|
|
196
|
+
} else if (res.start !== void 0 && res.end !== void 0) {
|
|
197
|
+
console.log(`resolved: ${res.start}s - ${res.end}s${res.occurrence !== void 0 ? ` (occurrence ${res.occurrence})` : ""}`);
|
|
198
|
+
if (typeof res.context === "string") console.log(`context: ${res.context}`);
|
|
199
|
+
} else if (Array.isArray(res.candidates)) {
|
|
200
|
+
console.log(`ambiguous: ${res.candidates.length} occurrence(s); pass --occurrence`);
|
|
201
|
+
for (const c of res.candidates) {
|
|
202
|
+
console.log(` #${c.occurrence} ${c.start}s - ${c.end}s: ${c.context ?? ""}`);
|
|
203
|
+
}
|
|
204
|
+
process.exitCode = 3;
|
|
205
|
+
} else if (Array.isArray(res.suggestions)) {
|
|
206
|
+
console.log(`not found: "${o.phrase}"`);
|
|
207
|
+
for (const s of res.suggestions) {
|
|
208
|
+
console.log(` did you mean "${s.text}" @${s.start}s (similarity ${s.similarity})`);
|
|
209
|
+
}
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
} else {
|
|
212
|
+
console.log(JSON.stringify(res, null, 2));
|
|
896
213
|
}
|
|
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
|
-
});
|
|
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
214
|
});
|
|
978
|
-
console.log(output);
|
|
979
215
|
});
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
} else {
|
|
985
|
-
for (const e of errors) console.log(` [error] ${e.path}: ${e.message}`);
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
timelineCmd.command("validate").description("Schema + semantic + capability validation of <job>/timeline.json").requiredOption("--job-dir <path>", "Job directory containing timeline.json").option("--json", "Print the JSON verdict").action(async (o) => {
|
|
989
|
-
const jobDir = resolvePath(o.jobDir);
|
|
990
|
-
const loaded = loadTimeline(jobDir);
|
|
216
|
+
async function runRemoteCompile(o) {
|
|
217
|
+
const jobDir = jobDirOf(o);
|
|
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
|
|
231
|
+
const res = await compileRemote(client, jobDir, loaded);
|
|
232
|
+
const diagnostics = res.response.diagnostics;
|
|
233
|
+
const job = readJobFile(jobDir);
|
|
234
|
+
const webUrl = res.ok && job && job.project_id !== "" ? webVideoUrl(client.cfg.base, job.project_id, job.video_id) : null;
|
|
1024
235
|
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
|
-
);
|
|
236
|
+
console.log(JSON.stringify({ ok: res.ok, plan: res.planPath ?? null, edl: res.edlPath ?? null, diagnostics: res.response.diagnostics ?? null, uses: res.response.uses ?? null, web_url: webUrl }, null, 2));
|
|
1041
237
|
} else {
|
|
1042
|
-
if (
|
|
1043
|
-
console.log(`plan: ${
|
|
1044
|
-
console.log(`edl: ${
|
|
1045
|
-
console.log(
|
|
1046
|
-
`output: ${result.plan.outputDurationS.toFixed(2)}s, ${result.plan.segments.length} segment(s), ${result.plan.events.length} event(s)`
|
|
1047
|
-
);
|
|
1048
|
-
console.log(`uses: ${result.uses.join(", ") || "(none)"}`);
|
|
1049
|
-
}
|
|
1050
|
-
console.log(`diagnostics: ${written.diagnostics}`);
|
|
1051
|
-
for (const line of formatDiagnostics(result.diagnostics)) console.log(line);
|
|
1052
|
-
}
|
|
1053
|
-
if (!result.ok) {
|
|
1054
|
-
const ambiguous = result.diagnostics.errors.some((e) => e.code === "anchor-ambiguous");
|
|
1055
|
-
process.exitCode = ambiguous ? 3 : 2;
|
|
1056
|
-
}
|
|
1057
|
-
});
|
|
1058
|
-
timelineCmd.command("resolve").description("Resolve a transcript phrase to source seconds (exact, normalized match)").requiredOption("--job-dir <path>", "Job directory").requiredOption("--phrase <text>", "Phrase to locate in the word-level transcript").option("--near <srcSeconds>", "Disambiguate: pick the occurrence nearest this source time").option("--occurrence <n>", "Pick the n-th occurrence (1-based)").option("--source <name>", "Source name (default: the timeline's single source, or the job stem)").option("--json", "Print JSON").action(async (o) => {
|
|
1059
|
-
const jobDir = resolvePath(o.jobDir);
|
|
1060
|
-
let sourceName = o.source;
|
|
1061
|
-
if (!sourceName) {
|
|
1062
|
-
const loaded = loadTimeline(jobDir);
|
|
1063
|
-
if (loaded.doc) {
|
|
1064
|
-
const names = Object.keys(loaded.doc.sources);
|
|
1065
|
-
if (names.length !== 1) throw new Error(`timeline has ${names.length} sources (${names.join(", ")}); pass --source`);
|
|
1066
|
-
sourceName = names[0];
|
|
1067
|
-
} else {
|
|
1068
|
-
sourceName = loadJob(jobDir).stem;
|
|
238
|
+
if (res.ok && res.planPath) {
|
|
239
|
+
console.log(`plan: ${res.planPath}`);
|
|
240
|
+
if (res.edlPath) console.log(`edl: ${res.edlPath}`);
|
|
241
|
+
if (Array.isArray(res.response.uses)) console.log(`uses: ${res.response.uses.join(", ") || "(none)"}`);
|
|
1069
242
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
const stream = wordStream(transcript);
|
|
1075
|
-
const outcome = resolvePhrase(stream, o.phrase, { occurrence: num(o.occurrence), nearS: num(o.near) });
|
|
1076
|
-
if (outcome.status === "ok") {
|
|
1077
|
-
const m = outcome.match;
|
|
1078
|
-
const payload2 = { start: m.start, end: m.end, words: m.words, occurrence: m.occurrence };
|
|
1079
|
-
if (o.json) console.log(JSON.stringify(payload2, null, 2));
|
|
1080
|
-
else {
|
|
1081
|
-
console.log(`resolved: ${m.start}s - ${m.end}s (occurrence ${m.occurrence} of ${outcome.matches.length})`);
|
|
1082
|
-
console.log(`context: ${m.context}`);
|
|
243
|
+
console.log(`diagnostics: ${res.diagnosticsPath}`);
|
|
244
|
+
for (const e of diagnostics?.errors ?? []) {
|
|
245
|
+
const d = e;
|
|
246
|
+
console.log(` [error] ${d.path ?? ""}: ${d.message ?? ""}`);
|
|
1083
247
|
}
|
|
1084
|
-
|
|
248
|
+
if (webUrl) console.log(`View & comment: ${webUrl}`);
|
|
1085
249
|
}
|
|
1086
|
-
if (
|
|
1087
|
-
const
|
|
1088
|
-
|
|
1089
|
-
occurrence: c.occurrence,
|
|
1090
|
-
start: c.start,
|
|
1091
|
-
end: c.end,
|
|
1092
|
-
context: c.context
|
|
1093
|
-
}))
|
|
1094
|
-
};
|
|
1095
|
-
if (o.json) console.log(JSON.stringify(payload2, null, 2));
|
|
1096
|
-
else {
|
|
1097
|
-
console.log(`ambiguous: ${outcome.candidates.length} occurrence(s); pass --occurrence or --near`);
|
|
1098
|
-
for (const c of payload2.candidates) console.log(` #${c.occurrence} ${c.start}s - ${c.end}s: ${c.context}`);
|
|
1099
|
-
}
|
|
1100
|
-
process.exitCode = 3;
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
const payload = { error: `phrase not found: ${o.phrase}`, suggestions: outcome.suggestions };
|
|
1104
|
-
if (o.json) console.log(JSON.stringify(payload, null, 2));
|
|
1105
|
-
else {
|
|
1106
|
-
console.log(`not found: "${o.phrase}"`);
|
|
1107
|
-
for (const s of outcome.suggestions) console.log(` did you mean "${s.text}" @${s.start}s (similarity ${s.similarity})`);
|
|
250
|
+
if (!res.ok) {
|
|
251
|
+
const ambiguous = (diagnostics?.errors ?? []).some((e) => e.code === "anchor-ambiguous");
|
|
252
|
+
process.exitCode = ambiguous ? 3 : 2;
|
|
1108
253
|
}
|
|
1109
|
-
|
|
254
|
+
}
|
|
255
|
+
addCloudOptions(
|
|
256
|
+
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")
|
|
257
|
+
).action(async (o) => {
|
|
258
|
+
await withCloudErrors(!!o.json, () => runRemoteCompile(o));
|
|
1110
259
|
});
|
|
1111
260
|
var captionsCmd = timelineCmd.command("captions").description("Caption authoring helpers");
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
console.log(
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
null,
|
|
1129
|
-
2
|
|
1130
|
-
)
|
|
1131
|
-
);
|
|
1132
|
-
} else {
|
|
1133
|
-
console.log(`captions: enabled (preset ${o.preset}) in ${timelineJsonPath(jobDir)}`);
|
|
1134
|
-
console.log(`derived cues (dry compile): ${cueCount}`);
|
|
1135
|
-
for (const line of formatDiagnostics(result.diagnostics)) console.log(line);
|
|
1136
|
-
}
|
|
1137
|
-
if (!result.ok) process.exitCode = 2;
|
|
1138
|
-
});
|
|
1139
|
-
program.command("compose").description(
|
|
1140
|
-
"Build a local HyperFrames composition from resolved/plan.json (cut + captions/zooms/overlays); with --render, render it via the `hyperframes` CLI"
|
|
1141
|
-
).requiredOption("--job-dir <path>", "Job directory containing resolved/plan.json").option("--render", "Render the composition locally (requires the `hyperframes` CLI on PATH)").option("--quality <q>", "Render quality: draft|standard|high", "standard").option("--fps <n>", "Render frame rate", "30").option("--out <path>", "Rendered output path (default <job>/composed.mp4)").option("--proxy-crf <crf>", "libx264 CRF for the compressed video proxy", "23").option("--json", "Print JSON").action(async (o) => {
|
|
1142
|
-
const jobDir = resolvePath(o.jobDir);
|
|
1143
|
-
const planFile = planPath(jobDir);
|
|
1144
|
-
if (!existsSync3(planFile)) {
|
|
1145
|
-
throw new Error(
|
|
1146
|
-
`plan not found: ${planFile} \u2014 run \`video-editing timeline compile --job-dir "${jobDir}"\` first`
|
|
1147
|
-
);
|
|
1148
|
-
}
|
|
1149
|
-
const plan = JSON.parse(readFileSync2(planFile, "utf8"));
|
|
1150
|
-
const quality = String(o.quality);
|
|
1151
|
-
if (!["draft", "standard", "high"].includes(quality)) throw new Error("--quality must be draft|standard|high");
|
|
1152
|
-
const fps = num(o.fps) ?? 30;
|
|
1153
|
-
const proxyCrf = num(o.proxyCrf) ?? 23;
|
|
1154
|
-
const verticalSrc = join5(jobDir, "vertical_src.mp4");
|
|
1155
|
-
if (!existsSync3(verticalSrc)) {
|
|
1156
|
-
throw new Error(
|
|
1157
|
-
`vertical master not found: ${verticalSrc} \u2014 bake it first (e.g. \`video-editing cut-bad-retakes --job-dir "${jobDir}" --render none\`)`
|
|
1158
|
-
);
|
|
1159
|
-
}
|
|
1160
|
-
const preview = join5(jobDir, "preview.mp4");
|
|
1161
|
-
if (!existsSync3(preview)) {
|
|
1162
|
-
throw new Error(
|
|
1163
|
-
`preview not found: ${preview} \u2014 render the QC preview first (\`video-editing render --job-dir "${jobDir}" --mode preview\`)`
|
|
1164
|
-
);
|
|
1165
|
-
}
|
|
1166
|
-
const compDir = join5(jobDir, "composition");
|
|
1167
|
-
const mediaDir = join5(compDir, "media");
|
|
1168
|
-
mkdirSync4(mediaDir, { recursive: true });
|
|
1169
|
-
const stale = (out2, src) => !existsSync3(out2) || statSync3(out2).mtimeMs < statSync3(src).mtimeMs;
|
|
1170
|
-
const proxy = join5(mediaDir, "source.mp4");
|
|
1171
|
-
if (stale(proxy, verticalSrc)) {
|
|
1172
|
-
await run([
|
|
1173
|
-
"ffmpeg",
|
|
1174
|
-
"-y",
|
|
1175
|
-
"-hide_banner",
|
|
1176
|
-
"-loglevel",
|
|
1177
|
-
"error",
|
|
1178
|
-
"-stats",
|
|
1179
|
-
"-i",
|
|
1180
|
-
verticalSrc,
|
|
1181
|
-
"-c:v",
|
|
1182
|
-
"libx264",
|
|
1183
|
-
"-preset",
|
|
1184
|
-
"veryfast",
|
|
1185
|
-
"-crf",
|
|
1186
|
-
String(proxyCrf),
|
|
1187
|
-
"-maxrate",
|
|
1188
|
-
"6M",
|
|
1189
|
-
"-bufsize",
|
|
1190
|
-
"12M",
|
|
1191
|
-
"-c:a",
|
|
1192
|
-
"aac",
|
|
1193
|
-
"-b:a",
|
|
1194
|
-
"160k",
|
|
1195
|
-
"-movflags",
|
|
1196
|
-
"+faststart",
|
|
1197
|
-
proxy
|
|
1198
|
-
]);
|
|
1199
|
-
} else {
|
|
1200
|
-
process.stderr.write(" proxy up to date; skipping re-encode\n");
|
|
1201
|
-
}
|
|
1202
|
-
const audio = join5(mediaDir, "audio.m4a");
|
|
1203
|
-
if (stale(audio, preview)) {
|
|
1204
|
-
await run([
|
|
1205
|
-
"ffmpeg",
|
|
1206
|
-
"-y",
|
|
1207
|
-
"-hide_banner",
|
|
1208
|
-
"-loglevel",
|
|
1209
|
-
"error",
|
|
1210
|
-
"-stats",
|
|
1211
|
-
"-i",
|
|
1212
|
-
preview,
|
|
1213
|
-
"-vn",
|
|
1214
|
-
"-c:a",
|
|
1215
|
-
"aac",
|
|
1216
|
-
"-b:a",
|
|
1217
|
-
"160k",
|
|
1218
|
-
audio
|
|
1219
|
-
]);
|
|
1220
|
-
} else {
|
|
1221
|
-
process.stderr.write(" cut audio up to date; skipping re-extract\n");
|
|
1222
|
-
}
|
|
1223
|
-
const assetRefs = /* @__PURE__ */ new Set();
|
|
1224
|
-
for (const ev of plan.events ?? []) {
|
|
1225
|
-
if (ev.kind === "overlay" && (ev.overlay.kind === "image" || ev.overlay.kind === "sticker") && typeof ev.overlay.asset?.ref === "string") {
|
|
1226
|
-
assetRefs.add(ev.overlay.asset.ref);
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
for (const ref of assetRefs) {
|
|
1230
|
-
const srcPath = resolvePath(jobDir, ref);
|
|
1231
|
-
if (!existsSync3(srcPath)) {
|
|
1232
|
-
throw new Error(
|
|
1233
|
-
`asset not found: ${ref} (expected at ${srcPath}) \u2014 asset refs are job-dir-relative paths`
|
|
1234
|
-
);
|
|
1235
|
-
}
|
|
1236
|
-
const dest = join5(mediaDir, ref);
|
|
1237
|
-
mkdirSync4(dirname4(dest), { recursive: true });
|
|
1238
|
-
copyFileSync(srcPath, dest);
|
|
1239
|
-
}
|
|
1240
|
-
const tlForGrade = loadTimeline(jobDir);
|
|
1241
|
-
const grade = tlForGrade.doc && typeof tlForGrade.doc.settings.grade === "object" ? tlForGrade.doc.settings.grade : void 0;
|
|
1242
|
-
const entry = join5(compDir, COMPOSE_ENTRY);
|
|
1243
|
-
writeFileSync4(entry, composeHtml(plan, grade !== void 0 ? { grade } : {}), "utf8");
|
|
1244
|
-
const counts = composeCounts(plan);
|
|
1245
|
-
const out = resolvePath(o.out ?? join5(jobDir, "composed.mp4"));
|
|
1246
|
-
const renderCmd = ["hyperframes", "render", "--quality", quality, "--fps", String(fps), "--output", out];
|
|
1247
|
-
if (!o.render) {
|
|
1248
|
-
if (o.json) {
|
|
1249
|
-
console.log(JSON.stringify({ compositionDir: compDir, entry, ...counts }, null, 2));
|
|
1250
|
-
} else {
|
|
1251
|
-
console.log(`composition: ${compDir}`);
|
|
1252
|
-
console.log(`entry: ${entry}`);
|
|
1253
|
-
console.log(`segments: ${counts.segments}, cues: ${counts.cues}, zooms: ${counts.zooms}, overlays: ${counts.overlays}`);
|
|
1254
|
-
console.log(`render locally: cd ${shown([compDir])} && ${shown(renderCmd)}`);
|
|
1255
|
-
}
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
const probe = runSync(["hyperframes", "--version"], { check: false, echo: false });
|
|
1259
|
-
if (probe.code !== 0) {
|
|
1260
|
-
throw new Error("`hyperframes` CLI not found on PATH \u2014 install it with `npm install -g hyperframes`");
|
|
1261
|
-
}
|
|
1262
|
-
const res = await run(renderCmd, { cwd: compDir, check: false });
|
|
1263
|
-
if (res.code !== 0) {
|
|
1264
|
-
process.exitCode = res.code;
|
|
1265
|
-
process.stderr.write(`hyperframes render failed with exit ${res.code}
|
|
1266
|
-
`);
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1269
|
-
if (o.json) {
|
|
1270
|
-
console.log(JSON.stringify({ compositionDir: compDir, entry, out, ...counts }, null, 2));
|
|
1271
|
-
} else {
|
|
1272
|
-
console.log(`composition: ${compDir}`);
|
|
1273
|
-
console.log(`out: ${out}`);
|
|
1274
|
-
}
|
|
261
|
+
addCloudOptions(
|
|
262
|
+
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")
|
|
263
|
+
).action(async (o) => {
|
|
264
|
+
await withCloudErrors(!!o.json, async () => {
|
|
265
|
+
const jobDir = jobDirOf(o);
|
|
266
|
+
const tlPath = timelineJsonPath2(jobDir);
|
|
267
|
+
if (!existsSync(tlPath)) throw new Error(`timeline not found: ${tlPath}`);
|
|
268
|
+
const raw = JSON.parse(readFileSync(tlPath, "utf8"));
|
|
269
|
+
const captions = raw.captions !== null && typeof raw.captions === "object" ? raw.captions : {};
|
|
270
|
+
captions.enabled = true;
|
|
271
|
+
captions.preset = o.preset;
|
|
272
|
+
raw.captions = captions;
|
|
273
|
+
writeFileSync(tlPath, JSON.stringify(raw, null, 2) + "\n", "utf8");
|
|
274
|
+
if (!o.json) console.log(`captions: enabled (preset ${o.preset}) in ${tlPath}`);
|
|
275
|
+
await runRemoteCompile(o);
|
|
276
|
+
});
|
|
1275
277
|
});
|
|
1276
|
-
function cloudFlags(o) {
|
|
1277
|
-
return { apiBase: o.apiBase, apiKey: o.apiKey, videoId: o.videoId };
|
|
1278
|
-
}
|
|
1279
|
-
function addCloudOptions(cmd) {
|
|
1280
|
-
return cmd.option("--api-base <url>", "API base URL (else CLIPREADY_API_BASE / API_BASE)").option("--api-key <key>", "API credential (else CLIPREADY_API_KEY / RUN_TOKEN)").option("--video-id <id>", "Video id (else VIDEO_ID)");
|
|
1281
|
-
}
|
|
1282
|
-
async function withCloudErrors(json, fn) {
|
|
1283
|
-
try {
|
|
1284
|
-
await fn();
|
|
1285
|
-
} catch (err) {
|
|
1286
|
-
if (json) {
|
|
1287
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1288
|
-
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
1289
|
-
process.exitCode = 1;
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
throw err;
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
var cloudCmd = program.command("cloud").description("Cloud render + status (clipready render API)");
|
|
1296
278
|
addCloudOptions(
|
|
1297
|
-
|
|
279
|
+
program.command("verify").description("Content-verify the rendered preview fully server-side (no upload): start the run, poll, pull verify.json").option("--job-dir <path>", "Job directory (default: cwd)").option("--full", "Verify the whole preview instead of per-join windows").option("--window <s>", "Per-join window length in seconds").option("--poll-interval <s>", "Run poll interval in seconds", "5").option("--json", "Print JSON")
|
|
1298
280
|
).action(async (o) => {
|
|
1299
281
|
await withCloudErrors(!!o.json, async () => {
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
const
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
else {
|
|
1308
|
-
console.log(`render_id: ${created.renderId}`);
|
|
1309
|
-
console.log(`status: ${created.status}`);
|
|
1310
|
-
}
|
|
1311
|
-
return;
|
|
1312
|
-
}
|
|
1313
|
-
const intervalMs = (num(o.pollInterval) ?? 10) * 1e3;
|
|
1314
|
-
const state = await pollRender(client, created.renderId, {
|
|
1315
|
-
intervalMs,
|
|
1316
|
-
onTick: (s, elapsed) => {
|
|
1317
|
-
if (!o.json) process.stderr.write(` [${Math.round(elapsed / 1e3)}s] ${s.status}
|
|
1318
|
-
`);
|
|
1319
|
-
}
|
|
282
|
+
const jobDir = jobDirOf(o);
|
|
283
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
284
|
+
const res = await verifyCloud(client, jobDir, {
|
|
285
|
+
full: !!o.full,
|
|
286
|
+
windowS: num(o.window),
|
|
287
|
+
pollIntervalMs: (num(o.pollInterval) ?? 5) * 1e3,
|
|
288
|
+
verbose: !o.json
|
|
1320
289
|
});
|
|
1321
|
-
if (state.status === "failed") {
|
|
1322
|
-
const detail = state.error !== void 0 ? typeof state.error === "string" ? state.error : JSON.stringify(state.error) : "unknown error";
|
|
1323
|
-
throw new Error(`render ${created.renderId} failed: ${detail}`);
|
|
1324
|
-
}
|
|
1325
|
-
if (!state.videoUrl) throw new Error(`render ${created.renderId} completed without a video_url`);
|
|
1326
|
-
const out = o.out ? resolvePath(o.out) : defaultRenderOut(jobDir, mode);
|
|
1327
|
-
const dl = await client.download(state.videoUrl, out);
|
|
1328
290
|
if (o.json) {
|
|
1329
|
-
console.log(JSON.stringify({ ok: true,
|
|
291
|
+
console.log(JSON.stringify({ ok: true, run_id: res.runId, verify: res.verifyPath, verdict: res.verdict, findings: res.findings }, null, 2));
|
|
1330
292
|
} else {
|
|
1331
|
-
console.log(`
|
|
1332
|
-
console.log(`
|
|
1333
|
-
console.log(`out: ${dl.path} (${dl.bytes} bytes)`);
|
|
293
|
+
console.log(`verify: ${res.verdict ?? "(no verdict)"}${res.findings !== null ? ` (${res.findings} finding(s))` : ""}`);
|
|
294
|
+
console.log(`verify.json: ${res.verifyPath}`);
|
|
1334
295
|
}
|
|
296
|
+
if (res.verdict === "needs-fixes") process.exitCode = 2;
|
|
1335
297
|
});
|
|
1336
298
|
});
|
|
299
|
+
var qaCmd = program.command("qa").description("Cloud QC: server-rendered filmstrip frames, waveform (word-timing sidecar) + the combined inspect composite; windows in source seconds, or plan-addressed via --join / --out-start");
|
|
300
|
+
function framesWindows(jobDir, o, joinMode = "split") {
|
|
301
|
+
const start = num(o.start);
|
|
302
|
+
const end = num(o.end);
|
|
303
|
+
const joinIdx = num(o.join);
|
|
304
|
+
const outStart = num(o.outStart);
|
|
305
|
+
const outEnd = num(o.outEnd);
|
|
306
|
+
const modes = [start !== void 0 || end !== void 0, joinIdx !== void 0, outStart !== void 0 || outEnd !== void 0];
|
|
307
|
+
if (modes.filter(Boolean).length !== 1) {
|
|
308
|
+
throw new Error("pass exactly one addressing mode: --start/--end (source s), --join <i>, or --out-start/--out-end (output s)");
|
|
309
|
+
}
|
|
310
|
+
if (joinIdx !== void 0) {
|
|
311
|
+
const segs = readPlanSegments(jobDir);
|
|
312
|
+
const span = num(o.span) ?? 2;
|
|
313
|
+
return joinMode === "span" ? [joinSpanWindow(segs, joinIdx, span)] : joinSourceWindows(segs, joinIdx, span);
|
|
314
|
+
}
|
|
315
|
+
if (outStart !== void 0 || outEnd !== void 0) {
|
|
316
|
+
if (outStart === void 0 || outEnd === void 0) throw new Error("--out-start and --out-end are both required");
|
|
317
|
+
return outWindowToSourceWindows(readPlanSegments(jobDir), outStart, outEnd);
|
|
318
|
+
}
|
|
319
|
+
if (start === void 0 || end === void 0 || end <= start) throw new Error("--start and --end must be numbers with end > start");
|
|
320
|
+
return [{ start, end, suffix: "" }];
|
|
321
|
+
}
|
|
1337
322
|
addCloudOptions(
|
|
1338
|
-
|
|
1339
|
-
).action(async (
|
|
323
|
+
qaCmd.command("frames").description("Server-render a filmstrip PNG per window and download it under <job>/qa").option("--start <s>", "Window start (SOURCE seconds)").option("--end <s>", "Window end (SOURCE seconds)").option("--join <i>", "Plan join index (1-based): filmstrips for the last/first --span seconds of the two segments meeting there (-a tail, -b head)").option("--span <s>", "Seconds per side for --join", "2").option("--out-start <s>", "Window start in OUTPUT seconds (mapped through resolved/plan.json)").option("--out-end <s>", "Window end in OUTPUT seconds").option("--cols <n>", "Grid columns").option("--interval <s>", "Seconds between frames").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
|
|
324
|
+
).action(async (o) => {
|
|
1340
325
|
await withCloudErrors(!!o.json, async () => {
|
|
1341
|
-
const
|
|
1342
|
-
const
|
|
326
|
+
const jobDir = jobDirOf(o);
|
|
327
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
328
|
+
const windows = framesWindows(jobDir, o);
|
|
329
|
+
const dir = qaDir(jobDir);
|
|
330
|
+
const outputs = [];
|
|
331
|
+
for (const w of windows) {
|
|
332
|
+
const res = await client.qaFrames({ start: w.start, end: w.end, cols: num(o.cols), interval: num(o.interval) });
|
|
333
|
+
const png = framesPngPath(dir, w.start, w.end, w.suffix);
|
|
334
|
+
const dl = await client.download(res.url, png);
|
|
335
|
+
outputs.push({ png: dl.path, width: res.width, height: res.height, frames: res.frames, tStart: res.tStart, tEnd: res.tEnd });
|
|
336
|
+
}
|
|
1343
337
|
if (o.json) {
|
|
1344
|
-
console.log(JSON.stringify({ ok: true,
|
|
338
|
+
console.log(JSON.stringify({ ok: true, frames: outputs.map((r) => ({ png: r.png, width: r.width, height: r.height, frames: r.frames, t_start: r.tStart, t_end: r.tEnd })) }, null, 2));
|
|
1345
339
|
} else {
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
340
|
+
for (const r of outputs) {
|
|
341
|
+
console.log(`frames: ${r.png}`);
|
|
342
|
+
console.log(`grid: ${r.frames} frame(s), ${r.width}x${r.height}, t ${r.tStart}s\u2013${r.tEnd}s`);
|
|
343
|
+
}
|
|
1350
344
|
}
|
|
1351
|
-
if (state.status === "failed") process.exitCode = 1;
|
|
1352
345
|
});
|
|
1353
346
|
});
|
|
1354
|
-
var qaCmd = program.command("qa").description("Cloud QA: server-rendered filmstrip frames + waveform");
|
|
1355
347
|
addCloudOptions(
|
|
1356
|
-
qaCmd.command("
|
|
348
|
+
qaCmd.command("inspect").description("Server-render the combined close-look composite (filmstrip + waveform + word labels + red cut shading from the compiled plan) per window and download it under <job>/qa").option("--start <s>", "Window start (SOURCE seconds)").option("--end <s>", "Window end (SOURCE seconds)").option("--join <i>", "Plan join index (1-based): ONE continuous composite spanning the cut \u2014 --span s of kept tail, the red-shaded cut region, --span s of kept head").option("--span <s>", "Seconds per side for --join", "2").option("--out-start <s>", "Window start in OUTPUT seconds (mapped through resolved/plan.json)").option("--out-end <s>", "Window end in OUTPUT seconds").option("--n-frames <n>", "Filmstrip frame count", "10").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
|
|
1357
349
|
).action(async (o) => {
|
|
1358
350
|
await withCloudErrors(!!o.json, async () => {
|
|
1359
|
-
const
|
|
1360
|
-
const
|
|
1361
|
-
|
|
1362
|
-
const
|
|
1363
|
-
const
|
|
1364
|
-
const
|
|
1365
|
-
|
|
1366
|
-
|
|
351
|
+
const jobDir = jobDirOf(o);
|
|
352
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
353
|
+
const windows = framesWindows(jobDir, o, "span");
|
|
354
|
+
const dir = qaDir(jobDir);
|
|
355
|
+
const outputs = [];
|
|
356
|
+
for (const w of windows) {
|
|
357
|
+
const res = await client.qaInspect({ start: w.start, end: w.end, nFrames: num(o.nFrames) });
|
|
358
|
+
const png = inspectPngPath(dir, w.start, w.end, w.suffix);
|
|
359
|
+
const dl = await client.download(res.url, png);
|
|
360
|
+
outputs.push({ png: dl.path, tStart: res.tStart, tEnd: res.tEnd, cuts: res.cuts, cached: res.cached });
|
|
361
|
+
}
|
|
1367
362
|
if (o.json) {
|
|
1368
|
-
console.log(JSON.stringify({ ok: true,
|
|
363
|
+
console.log(JSON.stringify({ ok: true, inspect: outputs.map((r) => ({ png: r.png, t_start: r.tStart, t_end: r.tEnd, cuts: r.cuts, cached: r.cached })) }, null, 2));
|
|
1369
364
|
} else {
|
|
1370
|
-
|
|
1371
|
-
|
|
365
|
+
for (const r of outputs) {
|
|
366
|
+
console.log(`inspect: ${r.png}`);
|
|
367
|
+
console.log(`window: t ${r.tStart}s\u2013${r.tEnd}s, ${r.cuts.length} cut(s) shaded${r.cached ? " (cached)" : ""}`);
|
|
368
|
+
}
|
|
1372
369
|
}
|
|
1373
370
|
});
|
|
1374
371
|
});
|
|
1375
372
|
addCloudOptions(
|
|
1376
|
-
qaCmd.command("waveform").description("
|
|
373
|
+
qaCmd.command("waveform").description("Server-render a waveform PNG + word-timing sidecar for a source-time window").requiredOption("--start <s>", "Window start (SOURCE seconds)").requiredOption("--end <s>", "Window end (SOURCE seconds)").option("--job-dir <path>", "Job directory (default: cwd)").option("--json", "Print JSON")
|
|
1377
374
|
).action(async (o) => {
|
|
1378
375
|
await withCloudErrors(!!o.json, async () => {
|
|
376
|
+
const jobDir = jobDirOf(o);
|
|
377
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
1379
378
|
const start = num(o.start);
|
|
1380
379
|
const end = num(o.end);
|
|
1381
|
-
if (start === void 0 || end === void 0) throw new Error("--start and --end
|
|
1382
|
-
const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
|
|
380
|
+
if (start === void 0 || end === void 0 || end <= start) throw new Error("--start and --end must be numbers with end > start");
|
|
1383
381
|
const res = await client.qaWaveform({ start, end });
|
|
1384
|
-
const dir = qaDir(
|
|
382
|
+
const dir = qaDir(jobDir);
|
|
1385
383
|
const png = waveformPngPath(dir, start, end);
|
|
1386
384
|
const wordsPath = waveformWordsPath(dir, start, end);
|
|
1387
385
|
const dl = await client.download(res.url, png);
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
if (o.json) {
|
|
1391
|
-
|
|
1392
|
-
} else {
|
|
386
|
+
mkdirSync(dir, { recursive: true });
|
|
387
|
+
writeFileSync(wordsPath, JSON.stringify(res.words, null, 2) + "\n", "utf8");
|
|
388
|
+
if (o.json) console.log(JSON.stringify({ ok: true, png: dl.path, words_path: wordsPath, words: res.words.length }, null, 2));
|
|
389
|
+
else {
|
|
1393
390
|
console.log(`waveform: ${dl.path}`);
|
|
1394
391
|
console.log(`words: ${wordsPath} (${res.words.length} word(s))`);
|
|
1395
392
|
}
|
|
1396
393
|
});
|
|
1397
394
|
});
|
|
1398
|
-
var
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
if (
|
|
1412
|
-
|
|
1413
|
-
|
|
395
|
+
var filesCmd = program.command("files").description("Sync the video's virtual file system with the job dir (byte-exact, sha256-skipped)");
|
|
396
|
+
function registerFilesPull(cmd, deprecated) {
|
|
397
|
+
addCloudOptions(
|
|
398
|
+
cmd.description(
|
|
399
|
+
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)"
|
|
400
|
+
).option("--job-dir <path>", "Job directory (default: cwd)").option("--only <paths>", "Comma-separated subset of file paths").option("--json", "Print JSON")
|
|
401
|
+
).action(async (o) => {
|
|
402
|
+
await withCloudErrors(!!o.json, async () => {
|
|
403
|
+
if (deprecated) process.stderr.write("warning: `job pull` is deprecated; use `files pull`\n");
|
|
404
|
+
const jobDir = jobDirOf(o);
|
|
405
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
406
|
+
const only = typeof o.only === "string" && o.only.trim() !== "" ? o.only.split(",").map((s) => s.trim()).filter((s) => s !== "") : void 0;
|
|
407
|
+
const res = await pullFiles(client, jobDir, { only });
|
|
408
|
+
if (o.json) {
|
|
409
|
+
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));
|
|
410
|
+
} else {
|
|
411
|
+
console.log(`job: ${jobDir}`);
|
|
412
|
+
for (const p of res.pulled) console.log(` ${p.path} \u2192 ${p.localPath} (${p.bytes} bytes${p.skipped ? ", unchanged" : ""})`);
|
|
413
|
+
if (res.pulled.length === 0) console.log(" (no files pulled)");
|
|
414
|
+
if (res.missing.length > 0) console.log(` missing (requested, not on server): ${res.missing.join(", ")}`);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
1414
417
|
});
|
|
1415
|
-
}
|
|
1416
|
-
|
|
418
|
+
}
|
|
419
|
+
registerFilesPull(filesCmd.command("pull"), false);
|
|
420
|
+
registerFilesPull(program.command("job", { hidden: true }).command("pull"), true);
|
|
421
|
+
addCloudOptions(
|
|
422
|
+
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")
|
|
423
|
+
).action(async (paths, o) => {
|
|
1417
424
|
await withCloudErrors(!!o.json, async () => {
|
|
1418
|
-
const
|
|
425
|
+
const jobDir = jobDirOf(o);
|
|
426
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
427
|
+
const res = await pushFiles(client, jobDir, paths);
|
|
1419
428
|
if (o.json) {
|
|
1420
|
-
console.log(JSON.stringify({ ok: true,
|
|
429
|
+
console.log(JSON.stringify({ ok: true, pushed: res.map((p) => ({ path: p.path, bytes: p.bytes, mode: p.mode, skipped: p.skipped })) }, null, 2));
|
|
1421
430
|
} else {
|
|
1422
|
-
console.log(`
|
|
1423
|
-
console.log(`source: ${res.source}`);
|
|
1424
|
-
for (const f of res.files) console.log(` ${f}`);
|
|
431
|
+
for (const p of res) console.log(` ${p.path} (${p.bytes} bytes, ${p.mode})`);
|
|
1425
432
|
}
|
|
1426
433
|
});
|
|
1427
434
|
});
|
|
1428
|
-
var reviewCmd = program.command("review").description("Review rounds:
|
|
1429
|
-
reviewCmd.command("prompt").description("Write the review prompt, warnings, and/or open the review app").requiredOption("--job-dir <path>", "Job directory").option("--write-warnings", "Convert review.json to warnings.json").option("--open-app", "Open the review app at localhost:5180").action(async (o) => {
|
|
1430
|
-
const jobDir = resolvePath(o.jobDir);
|
|
1431
|
-
const job = loadJob(jobDir);
|
|
1432
|
-
const reviewPrompt = writeVideoEditingPrompt(jobDir, job.stem, "review.prompt.md", "review.prompt.md");
|
|
1433
|
-
console.log(`review_prompt: ${reviewPrompt}`);
|
|
1434
|
-
if (o.writeWarnings) console.log(`warnings: ${writeWarningsFromReview(jobDir)}`);
|
|
1435
|
-
if (o.openApp) {
|
|
1436
|
-
const url = `http://localhost:5180/?job=${join5(jobDir).split("/").pop()}`;
|
|
1437
|
-
await run(["open", url], { check: false, echo: false });
|
|
1438
|
-
console.log(url);
|
|
1439
|
-
}
|
|
1440
|
-
});
|
|
1441
|
-
function printPushCompileFailure(res, json) {
|
|
1442
|
-
if (json) {
|
|
1443
|
-
console.log(JSON.stringify({ ok: false, errors: res.errors, diagnostics: res.diagnostics }, null, 2));
|
|
1444
|
-
} else if (res.diagnostics) {
|
|
1445
|
-
for (const line of formatDiagnostics(res.diagnostics)) console.log(line);
|
|
1446
|
-
} else {
|
|
1447
|
-
for (const e of res.errors) console.log(` [error] ${e.path}: ${e.message}`);
|
|
1448
|
-
}
|
|
1449
|
-
process.exitCode = 2;
|
|
1450
|
-
}
|
|
435
|
+
var reviewCmd = program.command("review").description("Review rounds: session sync + progress/terminal pushes (plans compiled server-side)");
|
|
1451
436
|
addCloudOptions(
|
|
1452
437
|
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")
|
|
1453
438
|
).action(async (o) => {
|
|
1454
439
|
await withCloudErrors(!!o.json, async () => {
|
|
1455
|
-
const jobDir =
|
|
1456
|
-
const client = new CloudClient(
|
|
1457
|
-
const res = await
|
|
440
|
+
const jobDir = jobDirOf(o);
|
|
441
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
442
|
+
const res = await pullReview2(client, jobDir, { withSource: !!o.source });
|
|
1458
443
|
if (o.json) {
|
|
1459
|
-
console.log(
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
);
|
|
444
|
+
console.log(JSON.stringify({
|
|
445
|
+
ok: true,
|
|
446
|
+
job_dir: jobDir,
|
|
447
|
+
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,
|
|
448
|
+
wrote_timeline: res.wroteTimeline,
|
|
449
|
+
downloaded_source: res.downloadedSource
|
|
450
|
+
}, null, 2));
|
|
1479
451
|
} else {
|
|
1480
452
|
console.log(`job: ${jobDir}`);
|
|
1481
453
|
if (res.round) {
|
|
1482
454
|
console.log(`round: ${res.round.roundId} (run ${res.round.runId}, seq ${res.round.seq}, ${res.round.mode}, ${res.round.status})`);
|
|
1483
|
-
console.log(`instruction: ${
|
|
455
|
+
console.log(`instruction: ${instructionPath2(jobDir)}`);
|
|
1484
456
|
console.log(`changes: ${res.round.changes.length}`);
|
|
1485
457
|
} else {
|
|
1486
458
|
console.log("no active review round");
|
|
1487
459
|
}
|
|
1488
|
-
console.log(`timeline.json: ${res.wroteTimeline ? "written" :
|
|
460
|
+
console.log(`timeline.json: ${res.wroteTimeline ? "written" : existsSync(timelineJsonPath2(jobDir)) ? "kept (already present locally)" : "not provided"}`);
|
|
1489
461
|
if (res.downloadedSource) console.log(`source: ${res.downloadedSource}`);
|
|
1490
462
|
}
|
|
1491
463
|
});
|
|
1492
464
|
});
|
|
465
|
+
function printPush2Failure(res, json) {
|
|
466
|
+
if (json) console.log(JSON.stringify({ ok: false, errors: res.errors, diagnostics: res.diagnostics }, null, 2));
|
|
467
|
+
else for (const e of res.errors) console.log(` [error] ${e.path}: ${e.message}`);
|
|
468
|
+
process.exitCode = 2;
|
|
469
|
+
}
|
|
1493
470
|
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: ${
|
|
471
|
+
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")
|
|
1495
472
|
).action(async (o) => {
|
|
1496
473
|
await withCloudErrors(!!o.json, async () => {
|
|
1497
|
-
const jobDir =
|
|
474
|
+
const jobDir = jobDirOf(o);
|
|
1498
475
|
const modeCount = [o.stage !== void 0, !!o.timeline, !!o.complete].filter(Boolean).length;
|
|
1499
|
-
if (modeCount !== 1)
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
|
|
1503
|
-
const runId = resolveRunId(jobDir, o.runId);
|
|
476
|
+
if (modeCount !== 1) throw new Error("pass exactly one of --stage <type>, --timeline, or --complete");
|
|
477
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
478
|
+
const runId = resolveRunId2(jobDir, o.runId);
|
|
1504
479
|
if (o.stage !== void 0) {
|
|
1505
|
-
await
|
|
480
|
+
await pushStage2(client, runId, o.stage, o.message);
|
|
1506
481
|
if (o.json) console.log(JSON.stringify({ ok: true, run_id: runId, type: o.stage }, null, 2));
|
|
1507
482
|
else console.log(`pushed: ${o.stage} (run ${runId})`);
|
|
1508
483
|
return;
|
|
1509
484
|
}
|
|
1510
485
|
if (o.timeline) {
|
|
1511
|
-
const res2 = await
|
|
1512
|
-
if (!res2.ok)
|
|
1513
|
-
|
|
1514
|
-
|
|
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));
|
|
1518
|
-
} else {
|
|
486
|
+
const res2 = await pushTimeline2(client, jobDir, runId);
|
|
487
|
+
if (!res2.ok) return printPush2Failure(res2, !!o.json);
|
|
488
|
+
if (o.json) console.log(JSON.stringify({ ok: true, run_id: runId, type: "timeline_update", timeline_hash: res2.timelineHash, assets: res2.assets }, null, 2));
|
|
489
|
+
else {
|
|
1519
490
|
console.log(`pushed: timeline_update (run ${runId})`);
|
|
1520
|
-
console.log(`
|
|
1521
|
-
console.log(`timeline_hash: ${res2.timelineHash}`);
|
|
491
|
+
if (res2.timelineHash) console.log(`timeline_hash: ${res2.timelineHash}`);
|
|
1522
492
|
if (Object.keys(res2.assets).length > 0) console.log(`assets: ${Object.keys(res2.assets).length} synced`);
|
|
1523
493
|
}
|
|
1524
494
|
return;
|
|
1525
495
|
}
|
|
1526
496
|
const durationS = num(o.duration);
|
|
1527
497
|
if (durationS === void 0) throw new Error("--complete requires --duration <seconds>");
|
|
1528
|
-
const res = await
|
|
498
|
+
const res = await pushComplete2(client, jobDir, runId, {
|
|
1529
499
|
durationS,
|
|
1530
500
|
outcomesPath: o.outcomes ? resolvePath(o.outcomes) : void 0
|
|
1531
501
|
});
|
|
1532
|
-
if (!res.ok)
|
|
1533
|
-
|
|
1534
|
-
|
|
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 {
|
|
502
|
+
if (!res.ok) return printPush2Failure(res, !!o.json);
|
|
503
|
+
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));
|
|
504
|
+
else {
|
|
1539
505
|
console.log(`pushed: completed (run ${runId})`);
|
|
1540
506
|
console.log(`duration_s: ${res.durationS}`);
|
|
1541
507
|
if (res.outcomes) console.log(`outcomes: ${res.outcomes.length} change(s)`);
|
|
@@ -1543,48 +509,237 @@ addCloudOptions(
|
|
|
1543
509
|
}
|
|
1544
510
|
});
|
|
1545
511
|
});
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
512
|
+
addCloudOptions(
|
|
513
|
+
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")
|
|
514
|
+
).action(async (o) => {
|
|
515
|
+
await withCloudErrors(!!o.json, async () => {
|
|
516
|
+
const jobDir = jobDirOf(o);
|
|
517
|
+
jobConfig(o, jobDir, { requireVideoId: false });
|
|
518
|
+
const path = instructionPath2(jobDir);
|
|
519
|
+
if (!existsSync(path)) throw new Error(`no instruction found at ${path} \u2014 run \`video-editing review pull\` first`);
|
|
520
|
+
if (o.json) console.log(JSON.stringify({ ok: true, instruction: path, content: readFileSync(path, "utf8") }, null, 2));
|
|
521
|
+
else {
|
|
522
|
+
console.log(`instruction: ${path}`);
|
|
523
|
+
process.stdout.write(readFileSync(path, "utf8"));
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
async function runCloudRender(o, wait) {
|
|
528
|
+
const mode = o.mode;
|
|
529
|
+
if (mode !== "preview" && mode !== "final") throw new Error("--mode must be preview|final");
|
|
530
|
+
const jobDir = jobDirOf(o);
|
|
531
|
+
const client = new CloudClient(jobConfig(o, jobDir));
|
|
532
|
+
const created = await client.createRender(mode);
|
|
533
|
+
if (!wait) {
|
|
534
|
+
if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: created.status }, null, 2));
|
|
535
|
+
else {
|
|
536
|
+
console.log(`render_id: ${created.renderId}`);
|
|
537
|
+
console.log(`status: ${created.status}`);
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
const intervalMs = (num(o.pollInterval) ?? 10) * 1e3;
|
|
542
|
+
const state = await pollRender(client, created.renderId, {
|
|
543
|
+
intervalMs,
|
|
544
|
+
onTick: (s, elapsed) => {
|
|
545
|
+
if (!o.json) process.stderr.write(` [${Math.round(elapsed / 1e3)}s] ${s.status}
|
|
546
|
+
`);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
if (state.status === "failed") {
|
|
550
|
+
const detail = state.error !== void 0 ? typeof state.error === "string" ? state.error : JSON.stringify(state.error) : "unknown error";
|
|
551
|
+
throw new Error(`render ${created.renderId} failed: ${detail}`);
|
|
552
|
+
}
|
|
553
|
+
if (!state.videoUrl) throw new Error(`render ${created.renderId} completed without a video_url`);
|
|
554
|
+
const out = o.out ? resolvePath(String(o.out)) : defaultRenderOut(jobDir, mode);
|
|
555
|
+
const dl = await client.download(state.videoUrl, out);
|
|
556
|
+
if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: state.status, video_url: state.videoUrl, out: dl.path, bytes: dl.bytes }, null, 2));
|
|
557
|
+
else {
|
|
558
|
+
console.log(`render_id: ${created.renderId}`);
|
|
559
|
+
console.log(`status: ${state.status}`);
|
|
560
|
+
console.log(`out: ${dl.path} (${dl.bytes} bytes)`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
addCloudOptions(
|
|
564
|
+
program.command("render").description("Cloud-render the compiled plan (works right after `timeline compile`); waits and downloads by default").option("--mode <mode>", "preview|final", "preview").option("--job-dir <path>", "Job directory (download target; default: cwd)").option("--no-wait", "Submit only; do not poll/download").option("--poll-interval <s>", "Poll interval in seconds", "10").option("--out <path>", "Output path (default <job>/preview.mp4 or final.mp4)").option("--json", "Print JSON")
|
|
565
|
+
).action(async (o) => {
|
|
566
|
+
await withCloudErrors(!!o.json, () => runCloudRender(o, o.wait !== false));
|
|
567
|
+
});
|
|
568
|
+
var cloudCmd = program.command("cloud").description("Cloud render + status (clipready render API)");
|
|
569
|
+
addCloudOptions(
|
|
570
|
+
cloudCmd.command("render").description("Submit a render; with --wait, poll to completion and download the result").requiredOption("--mode <mode>", "preview|final").option("--job-dir <path>", "Job directory (download target; default: cwd)").option("--wait", "Poll until the render is terminal, then download").option("--poll-interval <s>", "Poll interval in seconds", "10").option("--out <path>", "Output path (default <job>/preview.mp4 or final.mp4)").option("--json", "Print JSON")
|
|
571
|
+
).action(async (o) => {
|
|
572
|
+
await withCloudErrors(!!o.json, () => runCloudRender(o, !!o.wait));
|
|
573
|
+
});
|
|
574
|
+
addCloudOptions(
|
|
575
|
+
cloudCmd.command("status").description("Fetch the current status of a render").argument("<render_id>", "Render id").option("--json", "Print JSON")
|
|
576
|
+
).action(async (renderId, o) => {
|
|
577
|
+
await withCloudErrors(!!o.json, async () => {
|
|
578
|
+
const client = new CloudClient(resolveConfig(cloudFlags(o)));
|
|
579
|
+
const state = await client.getRender(renderId);
|
|
580
|
+
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));
|
|
581
|
+
else {
|
|
582
|
+
console.log(`render_id: ${renderId}`);
|
|
583
|
+
console.log(`status: ${state.status}`);
|
|
584
|
+
if (state.videoUrl) console.log(`video_url: ${state.videoUrl}`);
|
|
585
|
+
if (state.error !== void 0) console.log(`error: ${typeof state.error === "string" ? state.error : JSON.stringify(state.error)}`);
|
|
586
|
+
}
|
|
587
|
+
if (state.status === "failed") process.exitCode = 1;
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
addCloudOptions(
|
|
591
|
+
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")
|
|
592
|
+
).action(async (o) => {
|
|
593
|
+
await withCloudErrors(!!o.json, async () => {
|
|
594
|
+
resolveConfig(cloudFlags(o), { requireVideoId: false });
|
|
595
|
+
const res = installSkill({ target: o.target });
|
|
596
|
+
if (o.json) console.log(JSON.stringify({ ok: true, source: res.source, dest: res.dest, files: res.files }, null, 2));
|
|
597
|
+
else {
|
|
598
|
+
console.log(`installed skill: ${res.dest}`);
|
|
599
|
+
console.log(`source: ${res.source}`);
|
|
600
|
+
for (const f of res.files) console.log(` ${f}`);
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
var STATUS_FILES = [
|
|
605
|
+
"timeline.json",
|
|
606
|
+
"resolved/plan.json",
|
|
607
|
+
"resolved/diagnostics.json",
|
|
608
|
+
"edl.json",
|
|
609
|
+
"preview.mp4",
|
|
610
|
+
"final.mp4",
|
|
611
|
+
"takes_packed.md",
|
|
612
|
+
"flags.txt",
|
|
613
|
+
"word_dump.txt",
|
|
614
|
+
"coverage.json",
|
|
615
|
+
"probe.json",
|
|
616
|
+
"verify.json",
|
|
617
|
+
"review/round.json",
|
|
618
|
+
"review/instruction.md"
|
|
619
|
+
];
|
|
620
|
+
addCloudOptions(
|
|
621
|
+
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")
|
|
622
|
+
).action(async (o) => {
|
|
623
|
+
await withCloudErrors(!!o.json, async () => {
|
|
624
|
+
const jobDir = jobDirOf(o);
|
|
625
|
+
jobConfig(o, jobDir, { requireVideoId: false });
|
|
626
|
+
const job = readJobFile(jobDir);
|
|
627
|
+
const artifacts = {};
|
|
628
|
+
if (job) artifacts[`transcripts/${job.stem}.json`] = existsSync(join(jobDir, "transcripts", `${job.stem}.json`));
|
|
629
|
+
for (const f of STATUS_FILES) artifacts[f] = existsSync(join(jobDir, f));
|
|
630
|
+
if (o.json) {
|
|
631
|
+
console.log(JSON.stringify({ ok: true, job_dir: jobDir, job: job ?? null, artifacts }, null, 2));
|
|
632
|
+
} else {
|
|
633
|
+
console.log(`job: ${jobDir}`);
|
|
634
|
+
if (job) {
|
|
635
|
+
console.log(`video_id: ${job.video_id}`);
|
|
636
|
+
console.log(`project_id: ${job.project_id}`);
|
|
637
|
+
console.log(`stem: ${job.stem} (${job.duration_s}s)`);
|
|
638
|
+
} else {
|
|
639
|
+
console.log(`job.json: missing (${jobJsonPath(jobDir)}) \u2014 run \`video-editing init\``);
|
|
640
|
+
}
|
|
641
|
+
console.log("artifacts:");
|
|
642
|
+
for (const [name, ok] of Object.entries(artifacts)) console.log(` ${name}: ${ok ? "ok" : "missing"}`);
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
function promptSecret(promptText) {
|
|
647
|
+
return new Promise((resolve, reject) => {
|
|
648
|
+
const stdin = process.stdin;
|
|
649
|
+
process.stderr.write(promptText);
|
|
650
|
+
if (!stdin.isTTY) {
|
|
651
|
+
let data = "";
|
|
652
|
+
stdin.setEncoding("utf8");
|
|
653
|
+
stdin.on("data", (d) => data += d);
|
|
654
|
+
stdin.on("end", () => resolve(data.trim()));
|
|
655
|
+
stdin.resume();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
stdin.setRawMode(true);
|
|
659
|
+
stdin.resume();
|
|
660
|
+
stdin.setEncoding("utf8");
|
|
661
|
+
let value = "";
|
|
662
|
+
const done = (fn) => {
|
|
663
|
+
stdin.setRawMode(false);
|
|
664
|
+
stdin.pause();
|
|
665
|
+
stdin.off("data", onData);
|
|
666
|
+
process.stderr.write("\n");
|
|
667
|
+
fn();
|
|
668
|
+
};
|
|
669
|
+
const onData = (chunk) => {
|
|
670
|
+
for (const c of chunk) {
|
|
671
|
+
if (c === "\r" || c === "\n" || c === "") return done(() => resolve(value.trim()));
|
|
672
|
+
if (c === "") return done(() => reject(new Error("cancelled")));
|
|
673
|
+
if (c === "\x7F" || c === "\b") value = value.slice(0, -1);
|
|
674
|
+
else value += c;
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
stdin.on("data", onData);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
var authCmd = program.command("auth").description("Log in to ClipReady (stores the API key in the config file)");
|
|
681
|
+
authCmd.command("api-key").description("Paste your ClipReady API key once \u2014 verified against the cloud, then saved").argument("[key]", "API key (omit to be prompted; input is hidden)").option("--api-base <url>", "API base URL (default: the hosted ClipReady cloud)").action(async (keyArg, o) => {
|
|
682
|
+
const base = stripTrailingSlash(resolveApiBase(o.apiBase));
|
|
683
|
+
const key = (keyArg ?? await promptSecret(`Paste your ClipReady API key (from ${base}/settings): `)).trim();
|
|
684
|
+
if (!key) throw new Error("no API key provided");
|
|
685
|
+
let keyName = null;
|
|
686
|
+
try {
|
|
687
|
+
const me = await new CloudClient({ base, credential: key }).me();
|
|
688
|
+
const k = me.key;
|
|
689
|
+
keyName = typeof k?.name === "string" ? k.name : null;
|
|
690
|
+
} catch (err) {
|
|
691
|
+
throw new Error(
|
|
692
|
+
`that key was rejected by ${base} \u2014 check it at ${base}/settings and try again` + (err instanceof Error ? `
|
|
693
|
+
(${err.message})` : "")
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
savePublicConfigKey("CLIPREADY_API_KEY", key);
|
|
697
|
+
if (o.apiBase) savePublicConfigKey("CLIPREADY_API_BASE", base);
|
|
698
|
+
console.log(`Logged in${keyName ? ` with key "${keyName}"` : ""} (\u2026${key.slice(-4)}) \u2014 saved to ${publicConfigEnvPath()}`);
|
|
699
|
+
console.log(`Cloud: ${base}`);
|
|
700
|
+
});
|
|
701
|
+
authCmd.command("status").description("Show the active credential/base and verify them against the cloud").action(async () => {
|
|
702
|
+
loadPublicConfigEnv();
|
|
703
|
+
const base = stripTrailingSlash(resolveApiBase(void 0));
|
|
704
|
+
const key = resolveApiKey(void 0);
|
|
705
|
+
console.log(`config: ${publicConfigEnvPath()}`);
|
|
706
|
+
console.log(`cloud: ${base}`);
|
|
707
|
+
if (!key) {
|
|
708
|
+
console.log("key: (none) \u2014 run `video-editing auth api-key`");
|
|
709
|
+
process.exitCode = 1;
|
|
1551
710
|
return;
|
|
1552
711
|
}
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
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}`);
|
|
712
|
+
try {
|
|
713
|
+
const me = await new CloudClient({ base, credential: key }).me();
|
|
714
|
+
const k = me.key;
|
|
715
|
+
const keyName = typeof k?.name === "string" ? k.name : null;
|
|
716
|
+
console.log(`key: \u2026${key.slice(-4)}${keyName ? ` ("${keyName}")` : ""} \u2014 valid`);
|
|
717
|
+
} catch {
|
|
718
|
+
console.log(`key: \u2026${key.slice(-4)} \u2014 REJECTED by ${base}`);
|
|
719
|
+
process.exitCode = 1;
|
|
1569
720
|
}
|
|
1570
721
|
});
|
|
1571
|
-
|
|
722
|
+
authCmd.command("logout").description("Remove the stored API key").action(() => {
|
|
723
|
+
console.log(`removed key from ${removePublicConfigKey("CLIPREADY_API_KEY")}`);
|
|
724
|
+
});
|
|
725
|
+
program.command("config").description("Manage CLI config (clipready API key/base)").argument("<action>", "set-key | set-base | path").argument("[value]", "value for set-key / set-base").action((action, value) => {
|
|
1572
726
|
if (action === "path") {
|
|
1573
|
-
console.log(
|
|
727
|
+
console.log(publicConfigEnvPath());
|
|
1574
728
|
return;
|
|
1575
729
|
}
|
|
1576
730
|
if (action === "set-key") {
|
|
1577
731
|
if (!value) throw new Error("usage: video-editing config set-key <key>");
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
732
|
+
console.log(`wrote ${savePublicConfigKey("CLIPREADY_API_KEY", value)}`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (action === "set-base") {
|
|
736
|
+
if (!value) throw new Error("usage: video-editing config set-base <url>");
|
|
737
|
+
console.log(`wrote ${savePublicConfigKey("CLIPREADY_API_BASE", value)}`);
|
|
1582
738
|
return;
|
|
1583
739
|
}
|
|
1584
740
|
throw new Error(`unknown config action: ${action}`);
|
|
1585
741
|
});
|
|
1586
742
|
async function main() {
|
|
1587
|
-
requireFfmpeg();
|
|
1588
743
|
await program.parseAsync(process.argv);
|
|
1589
744
|
}
|
|
1590
745
|
main().catch((err) => {
|