video-editing 1.3.1
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/LICENSE +29 -0
- package/README.md +83 -0
- package/dist/assets/DejaVuSansMono.ttf +0 -0
- package/dist/chunk-63HIQBDT.js +4321 -0
- package/dist/chunk-CU3NBKB4.js +307 -0
- package/dist/cli.js +1594 -0
- package/dist/index.d.ts +3569 -0
- package/dist/index.js +300 -0
- package/dist/job-OFCSLPBV.js +25 -0
- package/dist/templates/author-edl.prompt.md +33 -0
- package/dist/templates/fix.prompt.md +23 -0
- package/dist/templates/review.prompt.md +30 -0
- package/package.json +69 -0
- package/skills/video-editing/SKILL.md +254 -0
- package/skills/video-editing/references/adversarial-review.md +44 -0
- package/skills/video-editing/references/cli-reference.md +181 -0
- package/skills/video-editing/references/editing-brief.md +44 -0
- package/skills/video-editing/references/hard-rules.md +38 -0
- package/skills/video-editing/references/scribe-collapse.md +37 -0
- package/skills/video-editing/references/timeline-v2-vocabulary.md +162 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
COMPOSE_ENTRY,
|
|
4
|
+
DEFAULT_DESILENCE,
|
|
5
|
+
artifactPaths,
|
|
6
|
+
checkCapabilities,
|
|
7
|
+
compileTimeline,
|
|
8
|
+
composeCounts,
|
|
9
|
+
composeHtml,
|
|
10
|
+
configDir,
|
|
11
|
+
configEnvPath,
|
|
12
|
+
cutBoundaries,
|
|
13
|
+
desilenceEdl,
|
|
14
|
+
edlPath,
|
|
15
|
+
ensureTranscribed,
|
|
16
|
+
ensureVerticalMaster,
|
|
17
|
+
formatDiagnostics,
|
|
18
|
+
gatherCompileCtx,
|
|
19
|
+
generateQcImages,
|
|
20
|
+
loadTimeline,
|
|
21
|
+
planPath,
|
|
22
|
+
readEdl,
|
|
23
|
+
render,
|
|
24
|
+
resolvePhrase,
|
|
25
|
+
statusPayload,
|
|
26
|
+
timelineJsonPath,
|
|
27
|
+
timelineView,
|
|
28
|
+
transcriptFileForSource,
|
|
29
|
+
updateCaptionsBlock,
|
|
30
|
+
usesFromDoc,
|
|
31
|
+
validateTimeline,
|
|
32
|
+
verify,
|
|
33
|
+
wordStream,
|
|
34
|
+
writeAgentManifest,
|
|
35
|
+
writeBoundaries,
|
|
36
|
+
writeCompileArtifacts,
|
|
37
|
+
writeSilencePrompt,
|
|
38
|
+
writeVideoEditingPrompt,
|
|
39
|
+
writeWarningsFromReview
|
|
40
|
+
} from "./chunk-63HIQBDT.js";
|
|
41
|
+
import {
|
|
42
|
+
initJob,
|
|
43
|
+
loadJob,
|
|
44
|
+
mediaDuration,
|
|
45
|
+
requireFfmpeg,
|
|
46
|
+
resolveJob,
|
|
47
|
+
run,
|
|
48
|
+
runSync,
|
|
49
|
+
shown
|
|
50
|
+
} from "./chunk-CU3NBKB4.js";
|
|
51
|
+
|
|
52
|
+
// src/cli.ts
|
|
53
|
+
import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync2, statSync as statSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
54
|
+
import { dirname as dirname4, join as join5, resolve as resolvePath } from "path";
|
|
55
|
+
import { Command } from "commander";
|
|
56
|
+
|
|
57
|
+
// src/cloud/client.ts
|
|
58
|
+
import { createWriteStream, mkdirSync, statSync, writeFileSync } from "fs";
|
|
59
|
+
import { dirname, join as pathJoin } from "path";
|
|
60
|
+
import { Readable } from "stream";
|
|
61
|
+
import { pipeline } from "stream/promises";
|
|
62
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
63
|
+
var DEFAULT_DOWNLOAD_TIMEOUT_MS = 3e5;
|
|
64
|
+
function firstNonEmpty(...vals) {
|
|
65
|
+
for (const v of vals) {
|
|
66
|
+
if (typeof v === "string" && v.trim() !== "") return v.trim();
|
|
67
|
+
}
|
|
68
|
+
return void 0;
|
|
69
|
+
}
|
|
70
|
+
function resolveApiBase(flag, env = process.env) {
|
|
71
|
+
return firstNonEmpty(flag, env.CLIPREADY_API_BASE, env.API_BASE);
|
|
72
|
+
}
|
|
73
|
+
function resolveApiKey(flag, env = process.env) {
|
|
74
|
+
return firstNonEmpty(flag, env.CLIPREADY_API_KEY, env.RUN_TOKEN);
|
|
75
|
+
}
|
|
76
|
+
function resolveVideoId(flag, env = process.env) {
|
|
77
|
+
return firstNonEmpty(flag, env.VIDEO_ID);
|
|
78
|
+
}
|
|
79
|
+
function stripTrailingSlash(base) {
|
|
80
|
+
return base.replace(/\/+$/, "");
|
|
81
|
+
}
|
|
82
|
+
function resolveConfig(flags, opts = {}, env = process.env) {
|
|
83
|
+
const base = resolveApiBase(flags.apiBase, env);
|
|
84
|
+
if (!base) {
|
|
85
|
+
throw new Error("missing API base URL \u2014 pass --api-base or set CLIPREADY_API_BASE (or API_BASE)");
|
|
86
|
+
}
|
|
87
|
+
const credential = resolveApiKey(flags.apiKey, env);
|
|
88
|
+
if (!credential) {
|
|
89
|
+
throw new Error("missing API credential \u2014 pass --api-key or set CLIPREADY_API_KEY (or RUN_TOKEN)");
|
|
90
|
+
}
|
|
91
|
+
const videoId = resolveVideoId(flags.videoId, env);
|
|
92
|
+
if (opts.requireVideoId && !videoId) {
|
|
93
|
+
throw new Error("missing video id \u2014 pass --video-id or set VIDEO_ID");
|
|
94
|
+
}
|
|
95
|
+
return { base: stripTrailingSlash(base), credential, videoId };
|
|
96
|
+
}
|
|
97
|
+
function rendersUrl(base) {
|
|
98
|
+
return `${stripTrailingSlash(base)}/api/v1/renders`;
|
|
99
|
+
}
|
|
100
|
+
function renderUrl(base, renderId) {
|
|
101
|
+
return `${stripTrailingSlash(base)}/api/v1/renders/${encodeURIComponent(renderId)}`;
|
|
102
|
+
}
|
|
103
|
+
function qaUrl(base, videoId) {
|
|
104
|
+
return `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/qa`;
|
|
105
|
+
}
|
|
106
|
+
function reviewUrl(base, videoId) {
|
|
107
|
+
return `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/review`;
|
|
108
|
+
}
|
|
109
|
+
function artifactsUrl(base, videoId, name) {
|
|
110
|
+
const root = `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/artifacts`;
|
|
111
|
+
return name ? `${root}?name=${encodeURIComponent(name)}` : root;
|
|
112
|
+
}
|
|
113
|
+
function assetsUrl(base) {
|
|
114
|
+
return `${stripTrailingSlash(base)}/api/v1/assets`;
|
|
115
|
+
}
|
|
116
|
+
function artifactLocalPath(jobDir, name) {
|
|
117
|
+
const parts = name.split("/").filter((p) => p !== "" && p !== "." && p !== "..");
|
|
118
|
+
return pathJoin(jobDir, ...parts);
|
|
119
|
+
}
|
|
120
|
+
function asRecord(data, ctx) {
|
|
121
|
+
if (data === null || typeof data !== "object") throw new Error(`unexpected ${ctx} response: ${JSON.stringify(data)}`);
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
function parseRenderCreate(data) {
|
|
125
|
+
const o = asRecord(data, "render-create");
|
|
126
|
+
const renderId = o.render_id;
|
|
127
|
+
if (typeof renderId !== "string" || renderId === "") throw new Error(`render response missing render_id: ${JSON.stringify(data)}`);
|
|
128
|
+
return { renderId, status: o.status ?? "pending" };
|
|
129
|
+
}
|
|
130
|
+
function parseRenderState(data) {
|
|
131
|
+
const o = asRecord(data, "render-status");
|
|
132
|
+
const status = o.status;
|
|
133
|
+
if (typeof status !== "string") throw new Error(`render status response missing status: ${JSON.stringify(data)}`);
|
|
134
|
+
return {
|
|
135
|
+
status,
|
|
136
|
+
videoUrl: typeof o.video_url === "string" ? o.video_url : void 0,
|
|
137
|
+
error: o.error
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function isTerminalStatus(s) {
|
|
141
|
+
return s === "completed" || s === "failed";
|
|
142
|
+
}
|
|
143
|
+
function parseArtifactList(data) {
|
|
144
|
+
const o = asRecord(data, "artifact-list");
|
|
145
|
+
const arr = o.artifacts;
|
|
146
|
+
if (!Array.isArray(arr)) throw new Error(`artifact list response missing artifacts[]: ${JSON.stringify(data)}`);
|
|
147
|
+
return arr.filter((n) => typeof n === "string");
|
|
148
|
+
}
|
|
149
|
+
function parseArtifactContent(data) {
|
|
150
|
+
const o = asRecord(data, "artifact");
|
|
151
|
+
const name = o.name;
|
|
152
|
+
if (typeof name !== "string") throw new Error(`artifact response missing name: ${JSON.stringify(data)}`);
|
|
153
|
+
return { name, content: o.content };
|
|
154
|
+
}
|
|
155
|
+
function parseAssetCreate(data) {
|
|
156
|
+
const o = asRecord(data, "asset-create");
|
|
157
|
+
const assetId = o.asset_id;
|
|
158
|
+
const uploadUrl = o.upload_url;
|
|
159
|
+
if (typeof assetId !== "string" || assetId === "") {
|
|
160
|
+
throw new Error(`asset create response missing asset_id: ${JSON.stringify(data)}`);
|
|
161
|
+
}
|
|
162
|
+
if (typeof uploadUrl !== "string" || uploadUrl === "") {
|
|
163
|
+
throw new Error(`asset create response missing upload_url: ${JSON.stringify(data)}`);
|
|
164
|
+
}
|
|
165
|
+
return { assetId, uploadUrl };
|
|
166
|
+
}
|
|
167
|
+
function parseQaFrames(data) {
|
|
168
|
+
const o = asRecord(data, "qa-frames");
|
|
169
|
+
if (typeof o.url !== "string") throw new Error(`qa frames response missing url: ${JSON.stringify(data)}`);
|
|
170
|
+
return {
|
|
171
|
+
url: o.url,
|
|
172
|
+
width: Number(o.width ?? 0),
|
|
173
|
+
height: Number(o.height ?? 0),
|
|
174
|
+
frames: Number(o.frames ?? 0),
|
|
175
|
+
tStart: Number(o.tStart ?? 0),
|
|
176
|
+
tEnd: Number(o.tEnd ?? 0)
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function parseQaWaveform(data) {
|
|
180
|
+
const o = asRecord(data, "qa-waveform");
|
|
181
|
+
if (typeof o.url !== "string") throw new Error(`qa waveform response missing url: ${JSON.stringify(data)}`);
|
|
182
|
+
const words = Array.isArray(o.words) ? o.words.map((w) => {
|
|
183
|
+
const r = w;
|
|
184
|
+
return { t: Number(r.t ?? 0), end: Number(r.end ?? 0), text: String(r.text ?? "") };
|
|
185
|
+
}) : [];
|
|
186
|
+
return { url: o.url, words };
|
|
187
|
+
}
|
|
188
|
+
function numOrNull(v) {
|
|
189
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
190
|
+
}
|
|
191
|
+
function strOrNull(v) {
|
|
192
|
+
return typeof v === "string" ? v : null;
|
|
193
|
+
}
|
|
194
|
+
function parseReviewSession(data) {
|
|
195
|
+
const o = asRecord(data, "review-session");
|
|
196
|
+
const videoId = o.video_id;
|
|
197
|
+
if (typeof videoId !== "string" || videoId === "") {
|
|
198
|
+
throw new Error(`review session response missing video_id: ${JSON.stringify(data)}`);
|
|
199
|
+
}
|
|
200
|
+
let activeRound = null;
|
|
201
|
+
if (o.active_round !== null && o.active_round !== void 0) {
|
|
202
|
+
const r = asRecord(o.active_round, "review-session active_round");
|
|
203
|
+
const roundId = r.round_id;
|
|
204
|
+
const runId = r.run_id;
|
|
205
|
+
if (typeof roundId !== "string" || typeof runId !== "string") {
|
|
206
|
+
throw new Error(`review session active_round missing round_id/run_id: ${JSON.stringify(o.active_round)}`);
|
|
207
|
+
}
|
|
208
|
+
const changes = Array.isArray(r.changes) ? r.changes.filter((c) => c !== null && typeof c === "object").map((c) => ({
|
|
209
|
+
id: String(c.id ?? ""),
|
|
210
|
+
idx: Number(c.idx ?? 0),
|
|
211
|
+
kind: String(c.kind ?? ""),
|
|
212
|
+
t: numOrNull(c.t),
|
|
213
|
+
t2: numOrNull(c.t2),
|
|
214
|
+
x: numOrNull(c.x),
|
|
215
|
+
y: numOrNull(c.y),
|
|
216
|
+
text: strOrNull(c.text),
|
|
217
|
+
outcome: strOrNull(c.outcome)
|
|
218
|
+
})) : [];
|
|
219
|
+
activeRound = {
|
|
220
|
+
roundId,
|
|
221
|
+
runId,
|
|
222
|
+
seq: Number(r.seq ?? 0),
|
|
223
|
+
mode: String(r.mode ?? ""),
|
|
224
|
+
status: String(r.status ?? ""),
|
|
225
|
+
instruction: typeof r.instruction === "string" ? r.instruction : "",
|
|
226
|
+
changes
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
videoId,
|
|
231
|
+
title: strOrNull(o.title),
|
|
232
|
+
latestVersionSeq: numOrNull(o.latest_version_seq),
|
|
233
|
+
activeRound,
|
|
234
|
+
timeline: o.timeline ?? null,
|
|
235
|
+
sourceUrl: strOrNull(o.source_url)
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
var CloudHttpError = class extends Error {
|
|
239
|
+
constructor(status, message, body) {
|
|
240
|
+
super(message);
|
|
241
|
+
this.status = status;
|
|
242
|
+
this.body = body;
|
|
243
|
+
this.name = "CloudHttpError";
|
|
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);
|
|
255
|
+
}
|
|
256
|
+
return void 0;
|
|
257
|
+
}
|
|
258
|
+
var CloudClient = class {
|
|
259
|
+
constructor(cfg, opts = {}) {
|
|
260
|
+
this.cfg = cfg;
|
|
261
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
262
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
263
|
+
if (!this.fetchImpl) throw new Error("global fetch is unavailable \u2014 Node 18+ required, or inject fetchImpl");
|
|
264
|
+
}
|
|
265
|
+
cfg;
|
|
266
|
+
fetchImpl;
|
|
267
|
+
timeoutMs;
|
|
268
|
+
requireVideoId() {
|
|
269
|
+
if (!this.cfg.videoId) throw new Error("missing video id \u2014 pass --video-id or set VIDEO_ID");
|
|
270
|
+
return this.cfg.videoId;
|
|
271
|
+
}
|
|
272
|
+
async request(method, url, body) {
|
|
273
|
+
const ac = new AbortController();
|
|
274
|
+
const timer = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
275
|
+
try {
|
|
276
|
+
const res = await this.fetchImpl(url, {
|
|
277
|
+
method,
|
|
278
|
+
headers: {
|
|
279
|
+
Authorization: `Bearer ${this.cfg.credential}`,
|
|
280
|
+
Accept: "application/json",
|
|
281
|
+
...body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
282
|
+
},
|
|
283
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
284
|
+
signal: ac.signal
|
|
285
|
+
});
|
|
286
|
+
const text = await res.text();
|
|
287
|
+
let data = void 0;
|
|
288
|
+
if (text) {
|
|
289
|
+
try {
|
|
290
|
+
data = JSON.parse(text);
|
|
291
|
+
} catch {
|
|
292
|
+
data = text;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!res.ok) {
|
|
296
|
+
const detail = extractErrorMessage(data) ?? `${res.status} ${res.statusText}`;
|
|
297
|
+
throw new CloudHttpError(res.status, `${method} ${url} failed: ${detail}`, data);
|
|
298
|
+
}
|
|
299
|
+
return data;
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (err instanceof CloudHttpError) throw err;
|
|
302
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
303
|
+
throw new Error(`request timed out after ${this.timeoutMs}ms: ${method} ${url}`);
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
} finally {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// ---- render -------------------------------------------------------------
|
|
311
|
+
async createRender(mode) {
|
|
312
|
+
const data = await this.request("POST", rendersUrl(this.cfg.base), {
|
|
313
|
+
video_id: this.requireVideoId(),
|
|
314
|
+
mode
|
|
315
|
+
});
|
|
316
|
+
return parseRenderCreate(data);
|
|
317
|
+
}
|
|
318
|
+
async getRender(renderId) {
|
|
319
|
+
const data = await this.request("GET", renderUrl(this.cfg.base, renderId));
|
|
320
|
+
return parseRenderState(data);
|
|
321
|
+
}
|
|
322
|
+
// ---- qa -----------------------------------------------------------------
|
|
323
|
+
async qaFrames(args) {
|
|
324
|
+
const body = { kind: "frames", start: args.start, end: args.end };
|
|
325
|
+
if (args.cols !== void 0) body.cols = args.cols;
|
|
326
|
+
if (args.interval !== void 0) body.interval = args.interval;
|
|
327
|
+
const data = await this.request("POST", qaUrl(this.cfg.base, this.requireVideoId()), body);
|
|
328
|
+
return parseQaFrames(data);
|
|
329
|
+
}
|
|
330
|
+
async qaWaveform(args) {
|
|
331
|
+
const data = await this.request("POST", qaUrl(this.cfg.base, this.requireVideoId()), {
|
|
332
|
+
kind: "waveform",
|
|
333
|
+
start: args.start,
|
|
334
|
+
end: args.end
|
|
335
|
+
});
|
|
336
|
+
return parseQaWaveform(data);
|
|
337
|
+
}
|
|
338
|
+
// ---- artifacts ----------------------------------------------------------
|
|
339
|
+
async listArtifacts() {
|
|
340
|
+
const data = await this.request("GET", artifactsUrl(this.cfg.base, this.requireVideoId()));
|
|
341
|
+
return parseArtifactList(data);
|
|
342
|
+
}
|
|
343
|
+
async getArtifact(name) {
|
|
344
|
+
const data = await this.request("GET", artifactsUrl(this.cfg.base, this.requireVideoId(), name));
|
|
345
|
+
return parseArtifactContent(data);
|
|
346
|
+
}
|
|
347
|
+
// ---- assets -------------------------------------------------------------
|
|
348
|
+
/** POST /api/v1/assets: request an asset row + presigned upload URL
|
|
349
|
+
* (clipready assets route: {kind, content_type?, video_id?, filename?} →
|
|
350
|
+
* {asset_id, upload_url, upload_token, expires_at}). */
|
|
351
|
+
async createAsset(args) {
|
|
352
|
+
const body = { kind: args.kind, video_id: this.requireVideoId() };
|
|
353
|
+
if (args.contentType !== void 0) body.content_type = args.contentType;
|
|
354
|
+
if (args.filename !== void 0) body.filename = args.filename;
|
|
355
|
+
const data = await this.request("POST", assetsUrl(this.cfg.base), body);
|
|
356
|
+
return parseAssetCreate(data);
|
|
357
|
+
}
|
|
358
|
+
/** PUT raw bytes to a presigned upload URL. No Authorization header — the
|
|
359
|
+
* signed URL carries its own credentials and may be a different host. */
|
|
360
|
+
async upload(url, bytes, contentType, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
361
|
+
const ac = new AbortController();
|
|
362
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
363
|
+
try {
|
|
364
|
+
const res = await this.fetchImpl(url, {
|
|
365
|
+
method: "PUT",
|
|
366
|
+
headers: { "Content-Type": contentType },
|
|
367
|
+
body: bytes,
|
|
368
|
+
signal: ac.signal
|
|
369
|
+
});
|
|
370
|
+
if (!res.ok) {
|
|
371
|
+
throw new Error(`asset upload failed: HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
372
|
+
}
|
|
373
|
+
} catch (err) {
|
|
374
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
375
|
+
throw new Error(`asset upload timed out after ${timeoutMs}ms: ${url}`);
|
|
376
|
+
}
|
|
377
|
+
throw err;
|
|
378
|
+
} finally {
|
|
379
|
+
clearTimeout(timer);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// ---- review session -----------------------------------------------------
|
|
383
|
+
async getReview() {
|
|
384
|
+
const data = await this.request("GET", reviewUrl(this.cfg.base, this.requireVideoId()));
|
|
385
|
+
return parseReviewSession(data);
|
|
386
|
+
}
|
|
387
|
+
/** POST a review-round event ({run_id, type, payload?}); expects {ok:true}. */
|
|
388
|
+
async pushReview(body) {
|
|
389
|
+
const data = await this.request("POST", reviewUrl(this.cfg.base, this.requireVideoId()), body);
|
|
390
|
+
const o = data;
|
|
391
|
+
if (!o || o.ok !== true) {
|
|
392
|
+
throw new Error(`review push (${body.type}) did not return {ok:true}: ${JSON.stringify(data)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ---- download (signed URLs; no auth header) -----------------------------
|
|
396
|
+
async download(url, dest, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
397
|
+
return downloadTo(url, dest, { fetchImpl: this.fetchImpl, timeoutMs });
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
async function downloadTo(url, dest, opts = {}) {
|
|
401
|
+
const f = opts.fetchImpl ?? globalThis.fetch;
|
|
402
|
+
const ac = new AbortController();
|
|
403
|
+
const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS);
|
|
404
|
+
try {
|
|
405
|
+
const res = await f(url, { signal: ac.signal });
|
|
406
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
407
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
408
|
+
const body = res.body;
|
|
409
|
+
if (!body) {
|
|
410
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
411
|
+
writeFileSync(dest, buf);
|
|
412
|
+
return { path: dest, bytes: buf.length };
|
|
413
|
+
}
|
|
414
|
+
await pipeline(Readable.fromWeb(body), createWriteStream(dest));
|
|
415
|
+
return { path: dest, bytes: statSync(dest).size };
|
|
416
|
+
} catch (err) {
|
|
417
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
418
|
+
throw new Error(`download timed out after ${opts.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS}ms: ${url}`);
|
|
419
|
+
}
|
|
420
|
+
throw err;
|
|
421
|
+
} finally {
|
|
422
|
+
clearTimeout(timer);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/cloud/review.ts
|
|
427
|
+
import { createHash } from "crypto";
|
|
428
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
429
|
+
import { basename, isAbsolute, join } from "path";
|
|
430
|
+
function reviewDir(jobDir) {
|
|
431
|
+
return join(jobDir, "review");
|
|
432
|
+
}
|
|
433
|
+
function roundJsonPath(jobDir) {
|
|
434
|
+
return join(reviewDir(jobDir), "round.json");
|
|
435
|
+
}
|
|
436
|
+
function instructionPath(jobDir) {
|
|
437
|
+
return join(reviewDir(jobDir), "instruction.md");
|
|
438
|
+
}
|
|
439
|
+
function assetsCachePath(jobDir) {
|
|
440
|
+
return join(reviewDir(jobDir), "assets.json");
|
|
441
|
+
}
|
|
442
|
+
function sourceDownloadPath(jobDir) {
|
|
443
|
+
const manifest = join(jobDir, "source.json");
|
|
444
|
+
if (existsSync(manifest)) {
|
|
445
|
+
try {
|
|
446
|
+
const src = JSON.parse(readFileSync(manifest, "utf8")).source;
|
|
447
|
+
if (typeof src === "string" && src !== "") return join(jobDir, basename(src));
|
|
448
|
+
} catch {
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return join(jobDir, "source.mp4");
|
|
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)) {
|
|
493
|
+
try {
|
|
494
|
+
const runId = JSON.parse(readFileSync(path, "utf8")).run_id;
|
|
495
|
+
if (typeof runId === "string" && runId !== "") return runId;
|
|
496
|
+
} catch {
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
throw new Error(`missing run id \u2014 pass --run-id, or run \`review pull\` first (no run_id in ${path})`);
|
|
500
|
+
}
|
|
501
|
+
var REVIEW_STAGES = [
|
|
502
|
+
"provisioning",
|
|
503
|
+
"transcribing",
|
|
504
|
+
"edl",
|
|
505
|
+
"render_preview",
|
|
506
|
+
"qc",
|
|
507
|
+
"render_final",
|
|
508
|
+
"uploading",
|
|
509
|
+
"log"
|
|
510
|
+
];
|
|
511
|
+
function isReviewStage(s) {
|
|
512
|
+
return REVIEW_STAGES.includes(s);
|
|
513
|
+
}
|
|
514
|
+
async function pushStage(client, runId, stage, message) {
|
|
515
|
+
if (!isReviewStage(stage)) {
|
|
516
|
+
throw new Error(`invalid --stage "${stage}" \u2014 expected one of: ${REVIEW_STAGES.join(", ")}`);
|
|
517
|
+
}
|
|
518
|
+
await client.pushReview({
|
|
519
|
+
run_id: runId,
|
|
520
|
+
type: stage,
|
|
521
|
+
...message !== void 0 ? { payload: { message } } : {}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function collectPlanAssetRefs(plan) {
|
|
525
|
+
const refs = [];
|
|
526
|
+
for (const ev of plan.events) {
|
|
527
|
+
const ref = ev.kind === "overlay" ? ev.overlay.asset?.ref : ev.kind === "audio" ? ev.audio.asset.ref : void 0;
|
|
528
|
+
if (typeof ref === "string" && ref !== "" && !refs.includes(ref)) refs.push(ref);
|
|
529
|
+
}
|
|
530
|
+
return refs;
|
|
531
|
+
}
|
|
532
|
+
var ASSET_CONTENT_TYPES = {
|
|
533
|
+
".png": "image/png",
|
|
534
|
+
".jpg": "image/jpeg",
|
|
535
|
+
".jpeg": "image/jpeg",
|
|
536
|
+
".webp": "image/webp",
|
|
537
|
+
".gif": "image/gif",
|
|
538
|
+
".mp3": "audio/mpeg",
|
|
539
|
+
".m4a": "audio/mp4",
|
|
540
|
+
".wav": "audio/wav"
|
|
541
|
+
};
|
|
542
|
+
function assetContentType(ref) {
|
|
543
|
+
const m = /\.[^./\\]+$/.exec(ref.toLowerCase());
|
|
544
|
+
return (m !== null ? ASSET_CONTENT_TYPES[m[0]] : void 0) ?? "application/octet-stream";
|
|
545
|
+
}
|
|
546
|
+
function readAssetCache(jobDir) {
|
|
547
|
+
const path = assetsCachePath(jobDir);
|
|
548
|
+
if (!existsSync(path)) return {};
|
|
549
|
+
try {
|
|
550
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
551
|
+
const out = {};
|
|
552
|
+
for (const [ref, v] of Object.entries(raw ?? {})) {
|
|
553
|
+
const e = v;
|
|
554
|
+
if (e && typeof e.sha256 === "string" && typeof e.asset_id === "string") {
|
|
555
|
+
out[ref] = { sha256: e.sha256, asset_id: e.asset_id };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
} catch {
|
|
560
|
+
return {};
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function syncAssets(client, jobDir, plan) {
|
|
564
|
+
const refs = collectPlanAssetRefs(plan);
|
|
565
|
+
if (refs.length === 0) return {};
|
|
566
|
+
const paths = /* @__PURE__ */ new Map();
|
|
567
|
+
for (const ref of refs) {
|
|
568
|
+
const abs = isAbsolute(ref) ? ref : join(jobDir, ref);
|
|
569
|
+
if (!existsSync(abs)) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
`asset not found: ${ref} (expected at ${abs}) \u2014 asset refs are job-dir-relative paths; place the file before pushing`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
paths.set(ref, abs);
|
|
575
|
+
}
|
|
576
|
+
const cache = readAssetCache(jobDir);
|
|
577
|
+
const manifest = {};
|
|
578
|
+
let dirty = false;
|
|
579
|
+
for (const ref of refs) {
|
|
580
|
+
const bytes = readFileSync(paths.get(ref));
|
|
581
|
+
const sha256 = "sha256:" + createHash("sha256").update(bytes).digest("hex");
|
|
582
|
+
const hit = cache[ref];
|
|
583
|
+
if (hit !== void 0 && hit.sha256 === sha256) {
|
|
584
|
+
manifest[ref] = hit.asset_id;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const contentType = assetContentType(ref);
|
|
588
|
+
const created = await client.createAsset({
|
|
589
|
+
kind: "timeline",
|
|
590
|
+
contentType,
|
|
591
|
+
filename: basename(ref)
|
|
592
|
+
});
|
|
593
|
+
await client.upload(created.uploadUrl, bytes, contentType);
|
|
594
|
+
cache[ref] = { sha256, asset_id: created.assetId };
|
|
595
|
+
manifest[ref] = created.assetId;
|
|
596
|
+
dirty = true;
|
|
597
|
+
}
|
|
598
|
+
if (dirty) {
|
|
599
|
+
mkdirSync2(reviewDir(jobDir), { recursive: true });
|
|
600
|
+
writeFileSync2(assetsCachePath(jobDir), JSON.stringify(cache, null, 2) + "\n", "utf8");
|
|
601
|
+
}
|
|
602
|
+
return manifest;
|
|
603
|
+
}
|
|
604
|
+
async function compileJob(jobDir, opts) {
|
|
605
|
+
const loaded = loadTimeline(jobDir);
|
|
606
|
+
if (!loaded.doc) return { ok: false, errors: loaded.errors, diagnostics: null };
|
|
607
|
+
const ctx = await gatherCompileCtx(jobDir, loaded.doc, opts.gather ?? {});
|
|
608
|
+
const result = compileTimeline(loaded.doc, ctx);
|
|
609
|
+
writeCompileArtifacts(jobDir, result);
|
|
610
|
+
if (!result.ok || !result.plan) {
|
|
611
|
+
return { ok: false, errors: result.diagnostics.errors, diagnostics: result.diagnostics };
|
|
612
|
+
}
|
|
613
|
+
return { ok: true, raw: loaded.raw, plan: result.plan, legacyEdl: result.legacyEdl };
|
|
614
|
+
}
|
|
615
|
+
async function pushTimeline(client, jobDir, runId, opts = {}) {
|
|
616
|
+
const compiled = await compileJob(jobDir, opts);
|
|
617
|
+
if (!compiled.ok) return compiled;
|
|
618
|
+
const assets = await syncAssets(client, jobDir, compiled.plan);
|
|
619
|
+
await client.pushReview({
|
|
620
|
+
run_id: runId,
|
|
621
|
+
type: "timeline_update",
|
|
622
|
+
payload: {
|
|
623
|
+
timeline: compiled.raw,
|
|
624
|
+
plan: compiled.plan,
|
|
625
|
+
timeline_hash: compiled.plan.timelineHash,
|
|
626
|
+
...Object.keys(assets).length > 0 ? { assets } : {}
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
return { ok: true, plan: compiled.plan, timelineHash: compiled.plan.timelineHash, assets };
|
|
630
|
+
}
|
|
631
|
+
function readOutcomesFile(path) {
|
|
632
|
+
if (!existsSync(path)) throw new Error(`outcomes file not found: ${path}`);
|
|
633
|
+
let raw;
|
|
634
|
+
try {
|
|
635
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
636
|
+
} catch (err) {
|
|
637
|
+
throw new Error(`outcomes file is not valid JSON: ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
638
|
+
}
|
|
639
|
+
const arr = Array.isArray(raw) ? raw : raw?.outcomes;
|
|
640
|
+
if (!Array.isArray(arr)) {
|
|
641
|
+
throw new Error(`outcomes file must be an array (or {outcomes: [...]}) of {change_id, ok, note?}: ${path}`);
|
|
642
|
+
}
|
|
643
|
+
return arr.map((o, i) => {
|
|
644
|
+
const r = o ?? {};
|
|
645
|
+
if (typeof r.change_id !== "string" || typeof r.ok !== "boolean" || r.note !== void 0 && typeof r.note !== "string") {
|
|
646
|
+
throw new Error(`outcomes[${i}] must be {change_id: string, ok: boolean, note?: string}: ${JSON.stringify(o)}`);
|
|
647
|
+
}
|
|
648
|
+
return { change_id: r.change_id, ok: r.ok, ...r.note !== void 0 ? { note: r.note } : {} };
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
async function pushComplete(client, jobDir, runId, opts) {
|
|
652
|
+
const outcomes = opts.outcomesPath ? readOutcomesFile(opts.outcomesPath) : void 0;
|
|
653
|
+
const compiled = await compileJob(jobDir, opts);
|
|
654
|
+
if (!compiled.ok) return compiled;
|
|
655
|
+
const assets = await syncAssets(client, jobDir, compiled.plan);
|
|
656
|
+
await client.pushReview({
|
|
657
|
+
run_id: runId,
|
|
658
|
+
type: "completed",
|
|
659
|
+
payload: {
|
|
660
|
+
edl: compiled.legacyEdl,
|
|
661
|
+
timeline: compiled.raw,
|
|
662
|
+
plan: compiled.plan,
|
|
663
|
+
duration_s: opts.durationS,
|
|
664
|
+
...outcomes !== void 0 ? { outcomes } : {},
|
|
665
|
+
...Object.keys(assets).length > 0 ? { assets } : {}
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
return { ok: true, plan: compiled.plan, durationS: opts.durationS, outcomes, assets };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/cloud/render.ts
|
|
672
|
+
import { join as join2 } from "path";
|
|
673
|
+
var MAX_POLL_MS = 30 * 60 * 1e3;
|
|
674
|
+
function defaultRenderOut(jobDir, mode) {
|
|
675
|
+
return join2(jobDir, mode === "final" ? "final.mp4" : "preview.mp4");
|
|
676
|
+
}
|
|
677
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
678
|
+
async function pollRender(client, renderId, opts = {}) {
|
|
679
|
+
const intervalMs = opts.intervalMs ?? 1e4;
|
|
680
|
+
const maxMs = opts.maxMs ?? MAX_POLL_MS;
|
|
681
|
+
const nap = opts.sleepImpl ?? sleep;
|
|
682
|
+
const now = opts.now ?? Date.now;
|
|
683
|
+
const start = now();
|
|
684
|
+
for (; ; ) {
|
|
685
|
+
const state = await client.getRender(renderId);
|
|
686
|
+
const elapsed = now() - start;
|
|
687
|
+
opts.onTick?.(state, elapsed);
|
|
688
|
+
if (isTerminalStatus(state.status)) return state;
|
|
689
|
+
if (elapsed + intervalMs >= maxMs) {
|
|
690
|
+
throw new Error(`render ${renderId} did not finish within ${Math.round(maxMs / 6e4)} min (last status: ${state.status})`);
|
|
691
|
+
}
|
|
692
|
+
await nap(intervalMs);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// src/cloud/qa.ts
|
|
697
|
+
import { join as join3 } from "path";
|
|
698
|
+
function qaDir(jobDir) {
|
|
699
|
+
return jobDir ? join3(jobDir, "qa") : join3(process.cwd(), "qa");
|
|
700
|
+
}
|
|
701
|
+
function tag(start, end) {
|
|
702
|
+
return `${start}_${end}`;
|
|
703
|
+
}
|
|
704
|
+
function framesPngPath(dir, start, end) {
|
|
705
|
+
return join3(dir, `frames_${tag(start, end)}.png`);
|
|
706
|
+
}
|
|
707
|
+
function waveformPngPath(dir, start, end) {
|
|
708
|
+
return join3(dir, `waveform_${tag(start, end)}.png`);
|
|
709
|
+
}
|
|
710
|
+
function waveformWordsPath(dir, start, end) {
|
|
711
|
+
return join3(dir, `waveform_${tag(start, end)}.words.json`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// src/cloud/pull.ts
|
|
715
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
716
|
+
import { dirname as dirname2 } from "path";
|
|
717
|
+
function selectArtifacts(available, only) {
|
|
718
|
+
if (!only || only.length === 0) return available;
|
|
719
|
+
const want = new Set(only.map((n) => n.trim()).filter((n) => n !== ""));
|
|
720
|
+
return available.filter((n) => want.has(n));
|
|
721
|
+
}
|
|
722
|
+
function parseOnly(value) {
|
|
723
|
+
if (!value) return void 0;
|
|
724
|
+
const names = value.split(",").map((n) => n.trim()).filter((n) => n !== "");
|
|
725
|
+
return names.length > 0 ? names : void 0;
|
|
726
|
+
}
|
|
727
|
+
function serializeArtifact(name, content) {
|
|
728
|
+
if (typeof content === "string") return content;
|
|
729
|
+
return JSON.stringify(content, null, 2) + "\n";
|
|
730
|
+
}
|
|
731
|
+
async function pullArtifacts(client, jobDir, only) {
|
|
732
|
+
const available = await client.listArtifacts();
|
|
733
|
+
const requested = only && only.length > 0 ? only : available;
|
|
734
|
+
const selected = selectArtifacts(available, only);
|
|
735
|
+
const missing = requested.filter((n) => !available.includes(n));
|
|
736
|
+
const pulled = [];
|
|
737
|
+
for (const name of selected) {
|
|
738
|
+
const art = await client.getArtifact(name);
|
|
739
|
+
const body = serializeArtifact(art.name, art.content);
|
|
740
|
+
const dest = artifactLocalPath(jobDir, art.name);
|
|
741
|
+
mkdirSync3(dirname2(dest), { recursive: true });
|
|
742
|
+
writeFileSync3(dest, body, "utf8");
|
|
743
|
+
pulled.push({ name: art.name, path: dest, bytes: Buffer.byteLength(body) });
|
|
744
|
+
}
|
|
745
|
+
return { jobDir, pulled, missing };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/cloud/skill.ts
|
|
749
|
+
import { cpSync, existsSync as existsSync2, readdirSync, rmSync, statSync as statSync2 } from "fs";
|
|
750
|
+
import { homedir } from "os";
|
|
751
|
+
import { dirname as dirname3, join as join4, relative, resolve } from "path";
|
|
752
|
+
import { fileURLToPath } from "url";
|
|
753
|
+
function bundledSkillDir() {
|
|
754
|
+
const here = dirname3(fileURLToPath(import.meta.url));
|
|
755
|
+
const candidates = [
|
|
756
|
+
join4(here, "skills", "video-editing"),
|
|
757
|
+
join4(here, "..", "skills", "video-editing"),
|
|
758
|
+
join4(here, "..", "..", "skills", "video-editing")
|
|
759
|
+
];
|
|
760
|
+
for (const c of candidates) {
|
|
761
|
+
if (existsSync2(join4(c, "SKILL.md"))) return resolve(c);
|
|
762
|
+
}
|
|
763
|
+
throw new Error(`bundled skill not found (looked in: ${candidates.map((c) => resolve(c)).join(", ")})`);
|
|
764
|
+
}
|
|
765
|
+
function defaultSkillTarget() {
|
|
766
|
+
return join4(homedir(), ".claude", "skills");
|
|
767
|
+
}
|
|
768
|
+
function listFilesRecursive(root) {
|
|
769
|
+
const out = [];
|
|
770
|
+
for (const entry of readdirSync(root)) {
|
|
771
|
+
const full = join4(root, entry);
|
|
772
|
+
if (statSync2(full).isDirectory()) out.push(...listFilesRecursive(full));
|
|
773
|
+
else out.push(full);
|
|
774
|
+
}
|
|
775
|
+
return out;
|
|
776
|
+
}
|
|
777
|
+
function installSkill(opts = {}) {
|
|
778
|
+
const source = bundledSkillDir();
|
|
779
|
+
const targetRoot = opts.target ? resolve(opts.target) : defaultSkillTarget();
|
|
780
|
+
const dest = join4(targetRoot, "video-editing");
|
|
781
|
+
rmSync(dest, { recursive: true, force: true });
|
|
782
|
+
cpSync(source, dest, { recursive: true });
|
|
783
|
+
const files = listFilesRecursive(dest).map((f) => relative(dest, f)).sort();
|
|
784
|
+
return { source, dest, files };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/cli.ts
|
|
788
|
+
function num(v) {
|
|
789
|
+
if (v === void 0) return void 0;
|
|
790
|
+
const n = Number(v);
|
|
791
|
+
return Number.isFinite(n) ? n : void 0;
|
|
792
|
+
}
|
|
793
|
+
async function maybeDesilence(jobDir, opts = {}) {
|
|
794
|
+
const edl = readEdl(jobDir);
|
|
795
|
+
if (!edl) return;
|
|
796
|
+
if (edl.desilenced && !opts.force) return;
|
|
797
|
+
const { edl: tight, stats } = await desilenceEdl(edl, jobDir, opts.options ?? DEFAULT_DESILENCE);
|
|
798
|
+
const rawBackup = join5(jobDir, "edl.raw.json");
|
|
799
|
+
if (!existsSync3(rawBackup)) writeFileSync4(rawBackup, JSON.stringify(edl, null, 2) + "\n", "utf8");
|
|
800
|
+
writeFileSync4(edlPath(jobDir), JSON.stringify(tight, null, 2) + "\n", "utf8");
|
|
801
|
+
const db = (opts.options ?? DEFAULT_DESILENCE).noiseDb;
|
|
802
|
+
process.stderr.write(
|
|
803
|
+
` desilence(${db}dB): ${stats.rangesBefore} \u2192 ${stats.rangesAfter} ranges, removed ${stats.removedS.toFixed(2)}s of silence (${stats.durationBefore.toFixed(2)}s \u2192 ${stats.durationAfter.toFixed(2)}s)
|
|
804
|
+
`
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
async function renderJob(jobDir, mode, opts = {}) {
|
|
808
|
+
if (mode === "none") return null;
|
|
809
|
+
if (!existsSync3(edlPath(jobDir))) return null;
|
|
810
|
+
if (!opts.noDesilence) await maybeDesilence(jobDir);
|
|
811
|
+
const name = { preview: "preview.mp4", draft: "draft.mp4", final: "final.mp4" }[mode];
|
|
812
|
+
const output = join5(jobDir, name);
|
|
813
|
+
await render(edlPath(jobDir), output, {
|
|
814
|
+
preview: mode === "preview",
|
|
815
|
+
draft: mode === "draft",
|
|
816
|
+
buildSubtitles: opts.buildSubtitles,
|
|
817
|
+
noSubtitles: opts.noSubtitles,
|
|
818
|
+
noLoudnorm: opts.noLoudnorm
|
|
819
|
+
});
|
|
820
|
+
return output;
|
|
821
|
+
}
|
|
822
|
+
function printPrepared(jobDir, prompt, edlRequired) {
|
|
823
|
+
console.log(`job: ${jobDir}`);
|
|
824
|
+
console.log(`prompt: ${prompt}`);
|
|
825
|
+
console.log(`manifest: ${join5(jobDir, "agent_manifest.json")}`);
|
|
826
|
+
console.log(`transcript: ${join5(jobDir, "transcripts")}`);
|
|
827
|
+
console.log(`flags: ${join5(jobDir, "flags.txt")}`);
|
|
828
|
+
console.log(`word_dump: ${join5(jobDir, "word_dump.txt")}`);
|
|
829
|
+
console.log(`vertical_src: ${join5(jobDir, "vertical_src.mp4")}`);
|
|
830
|
+
if (edlRequired) console.log(`edl missing: ${join5(jobDir, "edl.json")}`);
|
|
831
|
+
}
|
|
832
|
+
var pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
|
|
833
|
+
var program = new Command();
|
|
834
|
+
program.name("video-editing").description("Standalone TypeScript CLI for editing talking-head reels: transcribe, screen, cut (EDL), render vertical 9:16, QC, content-verify.").version(pkg.version);
|
|
835
|
+
program.command("init-job").description("Create a job directory and source.json").requiredOption("--source <path>", "Source video path").option("--job-dir <path>", "Job directory (default: ~/Downloads/edit/job_<source-stem>)").option("--force", "Overwrite source.json metadata").action(async (o) => {
|
|
836
|
+
const jobDir = await initJob(o.source, o.jobDir ?? null, { force: !!o.force });
|
|
837
|
+
console.log(jobDir);
|
|
838
|
+
});
|
|
839
|
+
program.command("transcribe").description("Run ElevenLabs Scribe transcription and screening artifacts").option("--source <path>", "Source video path").option("--job-dir <path>", "Existing job directory").option("--language <code>", "Optional ISO language code").option("--num-speakers <n>", "Optional speaker count").option("--no-heal", "Disable transcript healing").option("--force", "Re-transcribe even if cached").option("--api-key <key>", "ElevenLabs API key override").action(async (o) => {
|
|
840
|
+
const { jobDir, source, stem } = await resolveJob(o, { forceJob: !!o.force });
|
|
841
|
+
const tpath = await ensureTranscribed(jobDir, source, stem, {
|
|
842
|
+
language: o.language,
|
|
843
|
+
numSpeakers: num(o.numSpeakers),
|
|
844
|
+
noHeal: o.heal === false,
|
|
845
|
+
force: !!o.force,
|
|
846
|
+
apiKey: o.apiKey
|
|
847
|
+
});
|
|
848
|
+
writeAgentManifest(jobDir, source, stem, "transcribe");
|
|
849
|
+
console.log(`transcript: ${tpath}`);
|
|
850
|
+
console.log(`packed: ${join5(jobDir, "takes_packed.md")}`);
|
|
851
|
+
console.log(`flags: ${join5(jobDir, "flags.txt")}`);
|
|
852
|
+
});
|
|
853
|
+
program.command("cut-silences").description("Prepare a silence-cut prompt; render/QC if edl.json exists").option("--source <path>", "Source video path").option("--job-dir <path>", "Existing job directory").option("--min-gap <s>", "Document gaps at or above this many seconds", "0.30").option("--target-gap <s>", "Prompt target for remaining air", "0.13").option("--lead-pad <s>", "Prompt lead pad", "0.05").option("--trail-pad <s>", "Prompt trail pad", "0.08").option("--render <mode>", "Render if edl.json exists (preview|none)", "none").option("--force-transcribe", "Regenerate transcript before preparing").option("--api-key <key>", "ElevenLabs API key override").action(async (o) => {
|
|
854
|
+
const { jobDir, source, stem } = await resolveJob(o);
|
|
855
|
+
await ensureTranscribed(jobDir, source, stem, { force: !!o.forceTranscribe, apiKey: o.apiKey });
|
|
856
|
+
await ensureVerticalMaster(jobDir, source);
|
|
857
|
+
const prompt = writeSilencePrompt(jobDir, stem, {
|
|
858
|
+
minGap: num(o.minGap),
|
|
859
|
+
targetGap: num(o.targetGap),
|
|
860
|
+
leadPad: num(o.leadPad),
|
|
861
|
+
trailPad: num(o.trailPad)
|
|
862
|
+
});
|
|
863
|
+
writeAgentManifest(jobDir, source, stem, "cut-silences");
|
|
864
|
+
const preview = await renderJob(jobDir, o.render === "preview" ? "preview" : "none");
|
|
865
|
+
if (preview) {
|
|
866
|
+
writeBoundaries(jobDir);
|
|
867
|
+
console.log(`preview: ${preview}`);
|
|
868
|
+
}
|
|
869
|
+
printPrepared(jobDir, prompt, !existsSync3(edlPath(jobDir)));
|
|
870
|
+
});
|
|
871
|
+
program.command("cut-bad-retakes").description("Prepare the bad-retake (author-edl) prompt; render/QC if edl.json exists").option("--source <path>", "Source video path").option("--job-dir <path>", "Existing job directory").option("--with-sound-waves", "Write preview join QC PNGs if preview.mp4 exists").option("--quality-review", "Write review prompt; convert review.json to warnings if present").option("--render <mode>", "Render if edl.json exists (preview|none)", "none").option("--language <code>", "Optional ISO language code").option("--num-speakers <n>", "Optional speaker count").option("--force-transcribe", "Regenerate transcript before preparing").option("--api-key <key>", "ElevenLabs API key override").action(async (o) => {
|
|
872
|
+
const { jobDir, source, stem } = await resolveJob(o);
|
|
873
|
+
await ensureTranscribed(jobDir, source, stem, {
|
|
874
|
+
language: o.language,
|
|
875
|
+
numSpeakers: num(o.numSpeakers),
|
|
876
|
+
force: !!o.forceTranscribe,
|
|
877
|
+
apiKey: o.apiKey
|
|
878
|
+
});
|
|
879
|
+
await ensureVerticalMaster(jobDir, source);
|
|
880
|
+
const prompt = writeVideoEditingPrompt(jobDir, stem, "author-edl.prompt.md", "author-edl.prompt.md");
|
|
881
|
+
writeAgentManifest(jobDir, source, stem, "cut-bad-retakes");
|
|
882
|
+
const preview = await renderJob(jobDir, o.render === "preview" ? "preview" : "none");
|
|
883
|
+
if (preview) console.log(`preview: ${preview}`);
|
|
884
|
+
else if (o.render !== "none") process.stderr.write(`edl not found yet; write ${edlPath(jobDir)} from ${prompt}
|
|
885
|
+
`);
|
|
886
|
+
if (o.withSoundWaves) {
|
|
887
|
+
const qc = await generateQcImages(jobDir, preview ? { preview } : {});
|
|
888
|
+
if (qc) console.log(`qc: ${qc}`);
|
|
889
|
+
else process.stderr.write("qc not generated yet; it requires edl.json and preview.mp4\n");
|
|
890
|
+
}
|
|
891
|
+
if (o.qualityReview) {
|
|
892
|
+
const reviewPrompt = writeVideoEditingPrompt(jobDir, stem, "review.prompt.md", "review.prompt.md");
|
|
893
|
+
console.log(`review_prompt: ${reviewPrompt}`);
|
|
894
|
+
if (existsSync3(join5(jobDir, "review.json"))) {
|
|
895
|
+
console.log(`warnings: ${writeWarningsFromReview(jobDir)}`);
|
|
896
|
+
}
|
|
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
|
+
});
|
|
978
|
+
console.log(output);
|
|
979
|
+
});
|
|
980
|
+
var timelineCmd = program.command("timeline").description("TimelineV2 authoring: validate / compile / resolve phrases / captions");
|
|
981
|
+
function printTimelineErrors(errors, json) {
|
|
982
|
+
if (json) {
|
|
983
|
+
console.log(JSON.stringify({ ok: false, errors, warnings: [] }, null, 2));
|
|
984
|
+
} else {
|
|
985
|
+
for (const e of errors) console.log(` [error] ${e.path}: ${e.message}`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
timelineCmd.command("validate").description("Schema + semantic + capability validation of <job>/timeline.json").requiredOption("--job-dir <path>", "Job directory containing timeline.json").option("--json", "Print the JSON verdict").action(async (o) => {
|
|
989
|
+
const jobDir = resolvePath(o.jobDir);
|
|
990
|
+
const loaded = loadTimeline(jobDir);
|
|
991
|
+
if (!loaded.doc) {
|
|
992
|
+
printTimelineErrors(loaded.errors, !!o.json);
|
|
993
|
+
process.exitCode = 2;
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const sem = validateTimeline(loaded.doc);
|
|
997
|
+
const uses = usesFromDoc(loaded.doc);
|
|
998
|
+
const cap = checkCapabilities(uses);
|
|
999
|
+
const errors = [...sem.errors, ...cap.errors];
|
|
1000
|
+
const warnings = [...sem.warnings, ...cap.warnings];
|
|
1001
|
+
const ok = errors.length === 0;
|
|
1002
|
+
if (o.json) {
|
|
1003
|
+
console.log(JSON.stringify({ ok, uses, errors, warnings }, null, 2));
|
|
1004
|
+
} else {
|
|
1005
|
+
console.log(`timeline: ${timelineJsonPath(jobDir)}`);
|
|
1006
|
+
console.log(`valid: ${ok ? "yes" : "NO"} (${errors.length} error(s), ${warnings.length} warning(s))`);
|
|
1007
|
+
console.log(`uses: ${uses.join(", ") || "(none)"}`);
|
|
1008
|
+
for (const e of errors) console.log(` [error] ${e.path}: ${e.message}`);
|
|
1009
|
+
for (const w of warnings) console.log(` [warn] ${w.path}: ${w.message}`);
|
|
1010
|
+
}
|
|
1011
|
+
if (!ok) process.exitCode = 2;
|
|
1012
|
+
});
|
|
1013
|
+
timelineCmd.command("compile").description("Compile timeline.json -> resolved/plan.json + diagnostics + legacy edl.json").requiredOption("--job-dir <path>", "Job directory containing timeline.json").option("--json", "Print the JSON result").action(async (o) => {
|
|
1014
|
+
const jobDir = resolvePath(o.jobDir);
|
|
1015
|
+
const loaded = loadTimeline(jobDir);
|
|
1016
|
+
if (!loaded.doc) {
|
|
1017
|
+
printTimelineErrors(loaded.errors, !!o.json);
|
|
1018
|
+
process.exitCode = 2;
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const ctx = await gatherCompileCtx(jobDir, loaded.doc);
|
|
1022
|
+
const result = compileTimeline(loaded.doc, ctx);
|
|
1023
|
+
const written = writeCompileArtifacts(jobDir, result);
|
|
1024
|
+
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
|
+
);
|
|
1041
|
+
} else {
|
|
1042
|
+
if (result.ok && result.plan) {
|
|
1043
|
+
console.log(`plan: ${written.plan}`);
|
|
1044
|
+
console.log(`edl: ${written.edl}`);
|
|
1045
|
+
console.log(
|
|
1046
|
+
`output: ${result.plan.outputDurationS.toFixed(2)}s, ${result.plan.segments.length} segment(s), ${result.plan.events.length} event(s)`
|
|
1047
|
+
);
|
|
1048
|
+
console.log(`uses: ${result.uses.join(", ") || "(none)"}`);
|
|
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;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
const tfile = transcriptFileForSource(jobDir, sourceName);
|
|
1072
|
+
if (!tfile) throw new Error(`no transcript found for source "${sourceName}" in ${join5(jobDir, "transcripts")}`);
|
|
1073
|
+
const transcript = JSON.parse(readFileSync2(tfile, "utf8"));
|
|
1074
|
+
const stream = wordStream(transcript);
|
|
1075
|
+
const outcome = resolvePhrase(stream, o.phrase, { occurrence: num(o.occurrence), nearS: num(o.near) });
|
|
1076
|
+
if (outcome.status === "ok") {
|
|
1077
|
+
const m = outcome.match;
|
|
1078
|
+
const payload2 = { start: m.start, end: m.end, words: m.words, occurrence: m.occurrence };
|
|
1079
|
+
if (o.json) console.log(JSON.stringify(payload2, null, 2));
|
|
1080
|
+
else {
|
|
1081
|
+
console.log(`resolved: ${m.start}s - ${m.end}s (occurrence ${m.occurrence} of ${outcome.matches.length})`);
|
|
1082
|
+
console.log(`context: ${m.context}`);
|
|
1083
|
+
}
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (outcome.status === "ambiguous") {
|
|
1087
|
+
const payload2 = {
|
|
1088
|
+
candidates: outcome.candidates.map((c) => ({
|
|
1089
|
+
occurrence: c.occurrence,
|
|
1090
|
+
start: c.start,
|
|
1091
|
+
end: c.end,
|
|
1092
|
+
context: c.context
|
|
1093
|
+
}))
|
|
1094
|
+
};
|
|
1095
|
+
if (o.json) console.log(JSON.stringify(payload2, null, 2));
|
|
1096
|
+
else {
|
|
1097
|
+
console.log(`ambiguous: ${outcome.candidates.length} occurrence(s); pass --occurrence or --near`);
|
|
1098
|
+
for (const c of payload2.candidates) console.log(` #${c.occurrence} ${c.start}s - ${c.end}s: ${c.context}`);
|
|
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})`);
|
|
1108
|
+
}
|
|
1109
|
+
process.exitCode = 1;
|
|
1110
|
+
});
|
|
1111
|
+
var captionsCmd = timelineCmd.command("captions").description("Caption authoring helpers");
|
|
1112
|
+
captionsCmd.command("generate").description("Enable captions in timeline.json (preset only \u2014 cues are derived at compile)").requiredOption("--job-dir <path>", "Job directory containing timeline.json").option("--preset <name>", "Caption preset: grouped (default, ~4 words/cue) | opus-karaoke (word-by-word)", "grouped").option("--json", "Print JSON").action(async (o) => {
|
|
1113
|
+
const jobDir = resolvePath(o.jobDir);
|
|
1114
|
+
updateCaptionsBlock(jobDir, o.preset);
|
|
1115
|
+
const loaded = loadTimeline(jobDir);
|
|
1116
|
+
if (!loaded.doc) {
|
|
1117
|
+
printTimelineErrors(loaded.errors, !!o.json);
|
|
1118
|
+
process.exitCode = 2;
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
const ctx = await gatherCompileCtx(jobDir, loaded.doc, { noDetect: true });
|
|
1122
|
+
const result = compileTimeline(loaded.doc, ctx);
|
|
1123
|
+
const cueCount = result.plan?.events.filter((e) => e.kind === "caption-cue").length ?? 0;
|
|
1124
|
+
if (o.json) {
|
|
1125
|
+
console.log(
|
|
1126
|
+
JSON.stringify(
|
|
1127
|
+
{ ok: result.ok, timeline: timelineJsonPath(jobDir), preset: o.preset, cueCount, diagnostics: result.diagnostics },
|
|
1128
|
+
null,
|
|
1129
|
+
2
|
|
1130
|
+
)
|
|
1131
|
+
);
|
|
1132
|
+
} else {
|
|
1133
|
+
console.log(`captions: enabled (preset ${o.preset}) in ${timelineJsonPath(jobDir)}`);
|
|
1134
|
+
console.log(`derived cues (dry compile): ${cueCount}`);
|
|
1135
|
+
for (const line of formatDiagnostics(result.diagnostics)) console.log(line);
|
|
1136
|
+
}
|
|
1137
|
+
if (!result.ok) process.exitCode = 2;
|
|
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
|
+
}
|
|
1275
|
+
});
|
|
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
|
+
addCloudOptions(
|
|
1297
|
+
cloudCmd.command("render").description("Submit a render; with --wait, poll to completion and download the result").requiredOption("--job-dir <path>", "Job directory (download target)").requiredOption("--mode <mode>", "preview|final").option("--wait", "Poll until the render is terminal, then download").option("--poll-interval <s>", "Poll interval in seconds", "10").option("--out <path>", "Output path (default <job>/preview.mp4 or final.mp4)").option("--json", "Print JSON")
|
|
1298
|
+
).action(async (o) => {
|
|
1299
|
+
await withCloudErrors(!!o.json, async () => {
|
|
1300
|
+
const mode = o.mode;
|
|
1301
|
+
if (mode !== "preview" && mode !== "final") throw new Error("--mode must be preview|final");
|
|
1302
|
+
const jobDir = resolvePath(o.jobDir);
|
|
1303
|
+
const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
|
|
1304
|
+
const created = await client.createRender(mode);
|
|
1305
|
+
if (!o.wait) {
|
|
1306
|
+
if (o.json) console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: created.status }, null, 2));
|
|
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
|
+
}
|
|
1320
|
+
});
|
|
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
|
+
if (o.json) {
|
|
1329
|
+
console.log(JSON.stringify({ ok: true, render_id: created.renderId, status: state.status, video_url: state.videoUrl, out: dl.path, bytes: dl.bytes }, null, 2));
|
|
1330
|
+
} else {
|
|
1331
|
+
console.log(`render_id: ${created.renderId}`);
|
|
1332
|
+
console.log(`status: ${state.status}`);
|
|
1333
|
+
console.log(`out: ${dl.path} (${dl.bytes} bytes)`);
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
});
|
|
1337
|
+
addCloudOptions(
|
|
1338
|
+
cloudCmd.command("status").description("Fetch the current status of a render").argument("<render_id>", "Render id").option("--json", "Print JSON")
|
|
1339
|
+
).action(async (renderId, o) => {
|
|
1340
|
+
await withCloudErrors(!!o.json, async () => {
|
|
1341
|
+
const client = new CloudClient(resolveConfig(cloudFlags(o)));
|
|
1342
|
+
const state = await client.getRender(renderId);
|
|
1343
|
+
if (o.json) {
|
|
1344
|
+
console.log(JSON.stringify({ ok: true, render_id: renderId, status: state.status, video_url: state.videoUrl ?? null, error: state.error ?? null }, null, 2));
|
|
1345
|
+
} else {
|
|
1346
|
+
console.log(`render_id: ${renderId}`);
|
|
1347
|
+
console.log(`status: ${state.status}`);
|
|
1348
|
+
if (state.videoUrl) console.log(`video_url: ${state.videoUrl}`);
|
|
1349
|
+
if (state.error !== void 0) console.log(`error: ${typeof state.error === "string" ? state.error : JSON.stringify(state.error)}`);
|
|
1350
|
+
}
|
|
1351
|
+
if (state.status === "failed") process.exitCode = 1;
|
|
1352
|
+
});
|
|
1353
|
+
});
|
|
1354
|
+
var qaCmd = program.command("qa").description("Cloud QA: server-rendered filmstrip frames + waveform");
|
|
1355
|
+
addCloudOptions(
|
|
1356
|
+
qaCmd.command("frames").description("Render a filmstrip PNG for a window and download it locally").requiredOption("--start <s>", "Window start (seconds)").requiredOption("--end <s>", "Window end (seconds)").option("--cols <n>", "Grid columns").option("--interval <s>", "Seconds between frames").option("--job-dir <path>", "Job directory (QA files go under <job>/qa)").option("--json", "Print JSON")
|
|
1357
|
+
).action(async (o) => {
|
|
1358
|
+
await withCloudErrors(!!o.json, async () => {
|
|
1359
|
+
const start = num(o.start);
|
|
1360
|
+
const end = num(o.end);
|
|
1361
|
+
if (start === void 0 || end === void 0) throw new Error("--start and --end are required numbers");
|
|
1362
|
+
const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
|
|
1363
|
+
const res = await client.qaFrames({ start, end, cols: num(o.cols), interval: num(o.interval) });
|
|
1364
|
+
const dir = qaDir(o.jobDir ? resolvePath(o.jobDir) : void 0);
|
|
1365
|
+
const png = framesPngPath(dir, start, end);
|
|
1366
|
+
const dl = await client.download(res.url, png);
|
|
1367
|
+
if (o.json) {
|
|
1368
|
+
console.log(JSON.stringify({ ok: true, path: dl.path, width: res.width, height: res.height, frames: res.frames, tStart: res.tStart, tEnd: res.tEnd }, null, 2));
|
|
1369
|
+
} else {
|
|
1370
|
+
console.log(`frames: ${dl.path}`);
|
|
1371
|
+
console.log(`grid: ${res.frames} frame(s), ${res.width}x${res.height}, t ${res.tStart}s\u2013${res.tEnd}s`);
|
|
1372
|
+
}
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
addCloudOptions(
|
|
1376
|
+
qaCmd.command("waveform").description("Render a waveform PNG + word list for a window and download both").requiredOption("--start <s>", "Window start (seconds)").requiredOption("--end <s>", "Window end (seconds)").option("--job-dir <path>", "Job directory (QA files go under <job>/qa)").option("--json", "Print JSON")
|
|
1377
|
+
).action(async (o) => {
|
|
1378
|
+
await withCloudErrors(!!o.json, async () => {
|
|
1379
|
+
const start = num(o.start);
|
|
1380
|
+
const end = num(o.end);
|
|
1381
|
+
if (start === void 0 || end === void 0) throw new Error("--start and --end are required numbers");
|
|
1382
|
+
const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
|
|
1383
|
+
const res = await client.qaWaveform({ start, end });
|
|
1384
|
+
const dir = qaDir(o.jobDir ? resolvePath(o.jobDir) : void 0);
|
|
1385
|
+
const png = waveformPngPath(dir, start, end);
|
|
1386
|
+
const wordsPath = waveformWordsPath(dir, start, end);
|
|
1387
|
+
const dl = await client.download(res.url, png);
|
|
1388
|
+
mkdirSync4(dir, { recursive: true });
|
|
1389
|
+
writeFileSync4(wordsPath, JSON.stringify(res.words, null, 2) + "\n", "utf8");
|
|
1390
|
+
if (o.json) {
|
|
1391
|
+
console.log(JSON.stringify({ ok: true, png: dl.path, words_path: wordsPath, words: res.words.length }, null, 2));
|
|
1392
|
+
} else {
|
|
1393
|
+
console.log(`waveform: ${dl.path}`);
|
|
1394
|
+
console.log(`words: ${wordsPath} (${res.words.length} word(s))`);
|
|
1395
|
+
}
|
|
1396
|
+
});
|
|
1397
|
+
});
|
|
1398
|
+
var jobCmd = program.command("job").description("Job artifact sync from the cloud");
|
|
1399
|
+
addCloudOptions(
|
|
1400
|
+
jobCmd.command("pull").description("Fetch cloud artifacts into the job dir, preserving relative paths").option("--job-dir <path>", "Job directory (default: cwd)").option("--only <names>", "Comma-separated subset of artifact names").option("--json", "Print JSON")
|
|
1401
|
+
).action(async (o) => {
|
|
1402
|
+
await withCloudErrors(!!o.json, async () => {
|
|
1403
|
+
const jobDir = o.jobDir ? resolvePath(o.jobDir) : process.cwd();
|
|
1404
|
+
const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
|
|
1405
|
+
const result = await pullArtifacts(client, jobDir, parseOnly(o.only));
|
|
1406
|
+
if (o.json) {
|
|
1407
|
+
console.log(JSON.stringify({ ok: true, job_dir: jobDir, pulled: result.pulled, missing: result.missing }, null, 2));
|
|
1408
|
+
} else {
|
|
1409
|
+
console.log(`job: ${jobDir}`);
|
|
1410
|
+
for (const a of result.pulled) console.log(` ${a.name} \u2192 ${a.path} (${a.bytes} bytes)`);
|
|
1411
|
+
if (result.pulled.length === 0) console.log(" (no artifacts pulled)");
|
|
1412
|
+
if (result.missing.length > 0) console.log(` missing (requested, not on server): ${result.missing.join(", ")}`);
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
});
|
|
1416
|
+
program.command("skill").command("install").description("Install the bundled video-editing agent skill").option("--target <dir>", "Skills dir (default ~/.claude/skills)").option("--json", "Print JSON").action(async (o) => {
|
|
1417
|
+
await withCloudErrors(!!o.json, async () => {
|
|
1418
|
+
const res = installSkill({ target: o.target });
|
|
1419
|
+
if (o.json) {
|
|
1420
|
+
console.log(JSON.stringify({ ok: true, source: res.source, dest: res.dest, files: res.files }, null, 2));
|
|
1421
|
+
} else {
|
|
1422
|
+
console.log(`installed skill: ${res.dest}`);
|
|
1423
|
+
console.log(`source: ${res.source}`);
|
|
1424
|
+
for (const f of res.files) console.log(` ${f}`);
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
var reviewCmd = program.command("review").description("Review rounds: local prompt tooling + clipready review-session sync");
|
|
1429
|
+
reviewCmd.command("prompt").description("Write the review prompt, warnings, and/or open the review app").requiredOption("--job-dir <path>", "Job directory").option("--write-warnings", "Convert review.json to warnings.json").option("--open-app", "Open the review app at localhost:5180").action(async (o) => {
|
|
1430
|
+
const jobDir = resolvePath(o.jobDir);
|
|
1431
|
+
const job = loadJob(jobDir);
|
|
1432
|
+
const reviewPrompt = writeVideoEditingPrompt(jobDir, job.stem, "review.prompt.md", "review.prompt.md");
|
|
1433
|
+
console.log(`review_prompt: ${reviewPrompt}`);
|
|
1434
|
+
if (o.writeWarnings) console.log(`warnings: ${writeWarningsFromReview(jobDir)}`);
|
|
1435
|
+
if (o.openApp) {
|
|
1436
|
+
const url = `http://localhost:5180/?job=${join5(jobDir).split("/").pop()}`;
|
|
1437
|
+
await run(["open", url], { check: false, echo: false });
|
|
1438
|
+
console.log(url);
|
|
1439
|
+
}
|
|
1440
|
+
});
|
|
1441
|
+
function printPushCompileFailure(res, json) {
|
|
1442
|
+
if (json) {
|
|
1443
|
+
console.log(JSON.stringify({ ok: false, errors: res.errors, diagnostics: res.diagnostics }, null, 2));
|
|
1444
|
+
} else if (res.diagnostics) {
|
|
1445
|
+
for (const line of formatDiagnostics(res.diagnostics)) console.log(line);
|
|
1446
|
+
} else {
|
|
1447
|
+
for (const e of res.errors) console.log(` [error] ${e.path}: ${e.message}`);
|
|
1448
|
+
}
|
|
1449
|
+
process.exitCode = 2;
|
|
1450
|
+
}
|
|
1451
|
+
addCloudOptions(
|
|
1452
|
+
reviewCmd.command("pull").description("Pull the review session: round + instruction into <job>/review, timeline.json when absent").option("--job-dir <path>", "Job directory (default: cwd)").option("--source", "Also download the source video when absent locally").option("--json", "Print JSON")
|
|
1453
|
+
).action(async (o) => {
|
|
1454
|
+
await withCloudErrors(!!o.json, async () => {
|
|
1455
|
+
const jobDir = o.jobDir ? resolvePath(o.jobDir) : process.cwd();
|
|
1456
|
+
const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
|
|
1457
|
+
const res = await pullReview(client, jobDir, { withSource: !!o.source });
|
|
1458
|
+
if (o.json) {
|
|
1459
|
+
console.log(
|
|
1460
|
+
JSON.stringify(
|
|
1461
|
+
{
|
|
1462
|
+
ok: true,
|
|
1463
|
+
job_dir: jobDir,
|
|
1464
|
+
round: res.round ? {
|
|
1465
|
+
round_id: res.round.roundId,
|
|
1466
|
+
run_id: res.round.runId,
|
|
1467
|
+
seq: res.round.seq,
|
|
1468
|
+
mode: res.round.mode,
|
|
1469
|
+
status: res.round.status,
|
|
1470
|
+
changes: res.round.changes.length
|
|
1471
|
+
} : null,
|
|
1472
|
+
wrote_timeline: res.wroteTimeline,
|
|
1473
|
+
downloaded_source: res.downloadedSource
|
|
1474
|
+
},
|
|
1475
|
+
null,
|
|
1476
|
+
2
|
|
1477
|
+
)
|
|
1478
|
+
);
|
|
1479
|
+
} else {
|
|
1480
|
+
console.log(`job: ${jobDir}`);
|
|
1481
|
+
if (res.round) {
|
|
1482
|
+
console.log(`round: ${res.round.roundId} (run ${res.round.runId}, seq ${res.round.seq}, ${res.round.mode}, ${res.round.status})`);
|
|
1483
|
+
console.log(`instruction: ${instructionPath(jobDir)}`);
|
|
1484
|
+
console.log(`changes: ${res.round.changes.length}`);
|
|
1485
|
+
} else {
|
|
1486
|
+
console.log("no active review round");
|
|
1487
|
+
}
|
|
1488
|
+
console.log(`timeline.json: ${res.wroteTimeline ? "written" : existsSync3(timelineJsonPath(jobDir)) ? "kept (already present locally)" : "not provided"}`);
|
|
1489
|
+
if (res.downloadedSource) console.log(`source: ${res.downloadedSource}`);
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
});
|
|
1493
|
+
addCloudOptions(
|
|
1494
|
+
reviewCmd.command("push").description("Push review-round progress: a --stage event, a live --timeline update, or terminal --complete").option("--job-dir <path>", "Job directory (default: cwd)").option("--run-id <id>", "Run id (default: run_id from <job>/review/round.json)").option("--stage <type>", `Progress event type: ${REVIEW_STAGES.join("|")}`).option("--message <text>", "Optional message payload for --stage").option("--timeline", "Compile timeline.json and push a timeline_update (review page updates live)").option("--complete", "Compile fresh and push the terminal completed event").option("--duration <seconds>", "Total kept duration for --complete (required with --complete)").option("--outcomes <file>", "Per-change outcomes JSON for --complete ([{change_id, ok, note?}])").option("--json", "Print JSON")
|
|
1495
|
+
).action(async (o) => {
|
|
1496
|
+
await withCloudErrors(!!o.json, async () => {
|
|
1497
|
+
const jobDir = o.jobDir ? resolvePath(o.jobDir) : process.cwd();
|
|
1498
|
+
const modeCount = [o.stage !== void 0, !!o.timeline, !!o.complete].filter(Boolean).length;
|
|
1499
|
+
if (modeCount !== 1) {
|
|
1500
|
+
throw new Error("pass exactly one of --stage <type>, --timeline, or --complete");
|
|
1501
|
+
}
|
|
1502
|
+
const client = new CloudClient(resolveConfig(cloudFlags(o), { requireVideoId: true }));
|
|
1503
|
+
const runId = resolveRunId(jobDir, o.runId);
|
|
1504
|
+
if (o.stage !== void 0) {
|
|
1505
|
+
await pushStage(client, runId, o.stage, o.message);
|
|
1506
|
+
if (o.json) console.log(JSON.stringify({ ok: true, run_id: runId, type: o.stage }, null, 2));
|
|
1507
|
+
else console.log(`pushed: ${o.stage} (run ${runId})`);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
if (o.timeline) {
|
|
1511
|
+
const res2 = await pushTimeline(client, jobDir, runId);
|
|
1512
|
+
if (!res2.ok) {
|
|
1513
|
+
printPushCompileFailure(res2, !!o.json);
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
if (o.json) {
|
|
1517
|
+
console.log(JSON.stringify({ ok: true, run_id: runId, type: "timeline_update", timeline_hash: res2.timelineHash, output_duration_s: res2.plan.outputDurationS, segments: res2.plan.segments.length, assets: res2.assets }, null, 2));
|
|
1518
|
+
} else {
|
|
1519
|
+
console.log(`pushed: timeline_update (run ${runId})`);
|
|
1520
|
+
console.log(`output: ${res2.plan.outputDurationS.toFixed(2)}s, ${res2.plan.segments.length} segment(s)`);
|
|
1521
|
+
console.log(`timeline_hash: ${res2.timelineHash}`);
|
|
1522
|
+
if (Object.keys(res2.assets).length > 0) console.log(`assets: ${Object.keys(res2.assets).length} synced`);
|
|
1523
|
+
}
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
const durationS = num(o.duration);
|
|
1527
|
+
if (durationS === void 0) throw new Error("--complete requires --duration <seconds>");
|
|
1528
|
+
const res = await pushComplete(client, jobDir, runId, {
|
|
1529
|
+
durationS,
|
|
1530
|
+
outcomesPath: o.outcomes ? resolvePath(o.outcomes) : void 0
|
|
1531
|
+
});
|
|
1532
|
+
if (!res.ok) {
|
|
1533
|
+
printPushCompileFailure(res, !!o.json);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (o.json) {
|
|
1537
|
+
console.log(JSON.stringify({ ok: true, run_id: runId, type: "completed", duration_s: res.durationS, outcomes: res.outcomes?.length ?? 0, assets: res.assets }, null, 2));
|
|
1538
|
+
} else {
|
|
1539
|
+
console.log(`pushed: completed (run ${runId})`);
|
|
1540
|
+
console.log(`duration_s: ${res.durationS}`);
|
|
1541
|
+
if (res.outcomes) console.log(`outcomes: ${res.outcomes.length} change(s)`);
|
|
1542
|
+
if (Object.keys(res.assets).length > 0) console.log(`assets: ${Object.keys(res.assets).length} synced`);
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
});
|
|
1546
|
+
program.command("status").description("Print job status and artifact paths").requiredOption("--job-dir <path>", "Job directory").option("--json", "Print JSON").action(async (o) => {
|
|
1547
|
+
const jobDir = resolvePath(o.jobDir);
|
|
1548
|
+
const payload = await statusPayload(jobDir);
|
|
1549
|
+
if (o.json) {
|
|
1550
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
console.log(`job: ${payload.job_dir}`);
|
|
1554
|
+
console.log(`source: ${payload.source}`);
|
|
1555
|
+
const tr = payload.transcript;
|
|
1556
|
+
if (tr) console.log(`transcript: ${tr.word_count} words, coverage gap ${tr.coverage_gap_s.toFixed(2)}s`);
|
|
1557
|
+
const edl = payload.edl;
|
|
1558
|
+
if (edl) console.log(`edl: ${edl.range_count} ranges, ${edl.total_duration_s}s`);
|
|
1559
|
+
const prev = payload.preview;
|
|
1560
|
+
if (prev) console.log(`preview: ${prev.width}x${prev.height}, ${prev.duration_s.toFixed(2)}s`);
|
|
1561
|
+
const rev = payload.review;
|
|
1562
|
+
if (rev) console.log(`review: ${rev.verdict} (${rev.issue_count} issues)`);
|
|
1563
|
+
const ver = payload.verify;
|
|
1564
|
+
if (ver) console.log(`verify: ${ver.verdict} (${ver.finding_count} findings)`);
|
|
1565
|
+
console.log(`qc pngs: ${payload.qc_png_count}`);
|
|
1566
|
+
console.log("artifacts:");
|
|
1567
|
+
for (const [name, path] of Object.entries(artifactPaths(jobDir, loadJob(jobDir).stem))) {
|
|
1568
|
+
console.log(` ${name}: ${existsSync3(path) ? "ok" : "missing"} ${path}`);
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
program.command("config").description("Manage CLI config (ElevenLabs API key)").argument("<action>", "set-key | path").argument("[value]", "key value for set-key").action((action, value) => {
|
|
1572
|
+
if (action === "path") {
|
|
1573
|
+
console.log(configEnvPath());
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
if (action === "set-key") {
|
|
1577
|
+
if (!value) throw new Error("usage: video-editing config set-key <key>");
|
|
1578
|
+
mkdirSync4(configDir(), { recursive: true });
|
|
1579
|
+
writeFileSync4(configEnvPath(), `ELEVENLABS_API_KEY=${value}
|
|
1580
|
+
`, { mode: 384 });
|
|
1581
|
+
console.log(`wrote ${configEnvPath()}`);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
throw new Error(`unknown config action: ${action}`);
|
|
1585
|
+
});
|
|
1586
|
+
async function main() {
|
|
1587
|
+
requireFfmpeg();
|
|
1588
|
+
await program.parseAsync(process.argv);
|
|
1589
|
+
}
|
|
1590
|
+
main().catch((err) => {
|
|
1591
|
+
process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
|
|
1592
|
+
`);
|
|
1593
|
+
process.exit(1);
|
|
1594
|
+
});
|