video-editing 1.3.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -58
- package/dist/chunk-SSXAUKGV.js +2549 -0
- package/dist/cli.js +498 -1443
- package/dist/index.d.ts +1062 -921
- package/dist/index.js +297 -260
- package/package.json +2 -19
- package/skills/video-editing/SKILL.md +139 -232
- package/skills/video-editing/references/adversarial-review.md +3 -43
- package/skills/video-editing/references/cli-reference.md +124 -159
- package/skills/video-editing/references/editing-brief.md +3 -43
- package/skills/video-editing/references/hard-rules.md +3 -37
- package/skills/video-editing/references/scribe-collapse.md +3 -36
- package/skills/video-editing/references/timeline-v2-vocabulary.md +2 -2
- package/dist/assets/DejaVuSansMono.ttf +0 -0
- package/dist/chunk-63HIQBDT.js +0 -4321
- package/dist/chunk-CU3NBKB4.js +0 -307
- package/dist/job-OFCSLPBV.js +0 -25
- package/dist/templates/author-edl.prompt.md +0 -33
- package/dist/templates/fix.prompt.md +0 -23
- package/dist/templates/review.prompt.md +0 -30
|
@@ -0,0 +1,2549 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/ffmpeg.ts
|
|
4
|
+
import { spawn, spawnSync } from "child_process";
|
|
5
|
+
import { createWriteStream, mkdirSync } from "fs";
|
|
6
|
+
import { dirname } from "path";
|
|
7
|
+
function shquote(value) {
|
|
8
|
+
if (value.length === 0) return "''";
|
|
9
|
+
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
|
|
10
|
+
return "'" + value.replace(/'/g, `'"'"'`) + "'";
|
|
11
|
+
}
|
|
12
|
+
function shown(cmd) {
|
|
13
|
+
return cmd.map(shquote).join(" ");
|
|
14
|
+
}
|
|
15
|
+
var CommandError = class extends Error {
|
|
16
|
+
constructor(message, code, result) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.result = result;
|
|
20
|
+
this.name = "CommandError";
|
|
21
|
+
}
|
|
22
|
+
code;
|
|
23
|
+
result;
|
|
24
|
+
};
|
|
25
|
+
function run(cmd, opts = {}) {
|
|
26
|
+
const { capture = false, stdoutPath, cwd, check = true, echo = true } = opts;
|
|
27
|
+
if (echo) process.stderr.write(`+ ${shown(cmd)}
|
|
28
|
+
`);
|
|
29
|
+
return new Promise((resolve3, reject) => {
|
|
30
|
+
const [bin, ...args] = cmd;
|
|
31
|
+
if (!bin) {
|
|
32
|
+
reject(new Error("empty command"));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let fileStream;
|
|
36
|
+
const outChunks = [];
|
|
37
|
+
const errChunks = [];
|
|
38
|
+
const stdio = ["ignore", "pipe", "pipe"];
|
|
39
|
+
if (!capture && !stdoutPath) {
|
|
40
|
+
stdio[1] = "inherit";
|
|
41
|
+
stdio[2] = "inherit";
|
|
42
|
+
}
|
|
43
|
+
if (stdoutPath) {
|
|
44
|
+
mkdirSync(dirname(stdoutPath), { recursive: true });
|
|
45
|
+
fileStream = createWriteStream(stdoutPath, { encoding: "utf8" });
|
|
46
|
+
}
|
|
47
|
+
const child = spawn(bin, args, { cwd, stdio });
|
|
48
|
+
child.stdout?.on("data", (d) => {
|
|
49
|
+
if (fileStream) fileStream.write(d);
|
|
50
|
+
else if (capture) outChunks.push(d);
|
|
51
|
+
});
|
|
52
|
+
child.stderr?.on("data", (d) => {
|
|
53
|
+
if (fileStream) fileStream.write(d);
|
|
54
|
+
else if (capture) errChunks.push(d);
|
|
55
|
+
});
|
|
56
|
+
child.on("error", (err) => reject(err));
|
|
57
|
+
child.on("close", (code) => {
|
|
58
|
+
if (fileStream) fileStream.end();
|
|
59
|
+
const result = {
|
|
60
|
+
code: code ?? -1,
|
|
61
|
+
stdout: Buffer.concat(outChunks).toString("utf8"),
|
|
62
|
+
stderr: Buffer.concat(errChunks).toString("utf8")
|
|
63
|
+
};
|
|
64
|
+
if (check && result.code !== 0) {
|
|
65
|
+
if (capture) {
|
|
66
|
+
if (result.stdout) process.stderr.write(result.stdout + "\n");
|
|
67
|
+
if (result.stderr) process.stderr.write(result.stderr + "\n");
|
|
68
|
+
}
|
|
69
|
+
reject(new CommandError(`command failed with exit ${result.code}: ${shown(cmd)}`, result.code, result));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
resolve3(result);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function runSync(cmd, opts = {}) {
|
|
77
|
+
const { check = true, echo = true } = opts;
|
|
78
|
+
if (echo) process.stderr.write(`+ ${shown(cmd)}
|
|
79
|
+
`);
|
|
80
|
+
const [bin, ...args] = cmd;
|
|
81
|
+
const proc = spawnSync(bin, args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
82
|
+
const result = { code: proc.status ?? -1, stdout: proc.stdout ?? "", stderr: proc.stderr ?? "" };
|
|
83
|
+
if (check && result.code !== 0) {
|
|
84
|
+
throw new CommandError(`command failed with exit ${result.code}: ${shown(cmd)}`, result.code, result);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
var FFPROBE_ENTRIES = "stream=index,codec_type,codec_name,width,height,r_frame_rate,avg_frame_rate,duration,color_transfer:stream_side_data=rotation:format=duration,size,format_name";
|
|
89
|
+
async function ffprobe(path) {
|
|
90
|
+
const res = await run(
|
|
91
|
+
["ffprobe", "-v", "error", "-show_entries", FFPROBE_ENTRIES, "-of", "json", path],
|
|
92
|
+
{ capture: true }
|
|
93
|
+
);
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(res.stdout || "{}");
|
|
96
|
+
} catch (e) {
|
|
97
|
+
throw new Error(`ffprobe returned invalid JSON for ${path}: ${e.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function videoStream(probe) {
|
|
101
|
+
for (const s of probe.streams ?? []) {
|
|
102
|
+
if (s.codec_type === "video") return s;
|
|
103
|
+
}
|
|
104
|
+
throw new Error("no video stream found");
|
|
105
|
+
}
|
|
106
|
+
async function mediaDuration(path) {
|
|
107
|
+
const probe = await ffprobe(path);
|
|
108
|
+
const d = probe.format?.duration;
|
|
109
|
+
const n = d ? Number(d) : 0;
|
|
110
|
+
return Number.isFinite(n) ? n : 0;
|
|
111
|
+
}
|
|
112
|
+
function requireFfmpeg() {
|
|
113
|
+
for (const bin of ["ffmpeg", "ffprobe"]) {
|
|
114
|
+
const r = spawnSync(bin, ["-version"], { encoding: "utf8" });
|
|
115
|
+
if (r.error) {
|
|
116
|
+
throw new Error("ffmpeg and ffprobe must be on PATH");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/cloud/client.ts
|
|
122
|
+
import { createWriteStream as createWriteStream2, mkdirSync as mkdirSync2, statSync, writeFileSync } from "fs";
|
|
123
|
+
import { dirname as dirname2, join as pathJoin } from "path";
|
|
124
|
+
import { Readable } from "stream";
|
|
125
|
+
import { pipeline } from "stream/promises";
|
|
126
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
127
|
+
var DEFAULT_DOWNLOAD_TIMEOUT_MS = 3e5;
|
|
128
|
+
function firstNonEmpty(...vals) {
|
|
129
|
+
for (const v of vals) {
|
|
130
|
+
if (typeof v === "string" && v.trim() !== "") return v.trim();
|
|
131
|
+
}
|
|
132
|
+
return void 0;
|
|
133
|
+
}
|
|
134
|
+
function resolveApiBase(flag, env = process.env) {
|
|
135
|
+
return firstNonEmpty(flag, env.CLIPREADY_API_BASE, env.API_BASE);
|
|
136
|
+
}
|
|
137
|
+
function resolveApiKey(flag, env = process.env) {
|
|
138
|
+
return firstNonEmpty(flag, env.CLIPREADY_API_KEY, env.RUN_TOKEN);
|
|
139
|
+
}
|
|
140
|
+
function resolveVideoId(flag, env = process.env) {
|
|
141
|
+
return firstNonEmpty(flag, env.VIDEO_ID);
|
|
142
|
+
}
|
|
143
|
+
function stripTrailingSlash(base) {
|
|
144
|
+
return base.replace(/\/+$/, "");
|
|
145
|
+
}
|
|
146
|
+
function resolveConfig(flags, opts = {}, env = process.env) {
|
|
147
|
+
const base = resolveApiBase(flags.apiBase, env);
|
|
148
|
+
if (!base) {
|
|
149
|
+
throw new Error("missing API base URL \u2014 pass --api-base or set CLIPREADY_API_BASE (or API_BASE)");
|
|
150
|
+
}
|
|
151
|
+
const credential = resolveApiKey(flags.apiKey, env);
|
|
152
|
+
if (!credential) {
|
|
153
|
+
throw new Error("missing API credential \u2014 pass --api-key or set CLIPREADY_API_KEY (or RUN_TOKEN)");
|
|
154
|
+
}
|
|
155
|
+
const videoId = resolveVideoId(flags.videoId, env);
|
|
156
|
+
if (opts.requireVideoId && !videoId) {
|
|
157
|
+
throw new Error("missing video id \u2014 pass --video-id or set VIDEO_ID");
|
|
158
|
+
}
|
|
159
|
+
return { base: stripTrailingSlash(base), credential, videoId };
|
|
160
|
+
}
|
|
161
|
+
function rendersUrl(base) {
|
|
162
|
+
return `${stripTrailingSlash(base)}/api/v1/renders`;
|
|
163
|
+
}
|
|
164
|
+
function renderUrl(base, renderId) {
|
|
165
|
+
return `${stripTrailingSlash(base)}/api/v1/renders/${encodeURIComponent(renderId)}`;
|
|
166
|
+
}
|
|
167
|
+
function qaUrl(base, videoId) {
|
|
168
|
+
return `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/qa`;
|
|
169
|
+
}
|
|
170
|
+
function reviewUrl(base, videoId) {
|
|
171
|
+
return `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/review`;
|
|
172
|
+
}
|
|
173
|
+
function artifactsUrl(base, videoId, name) {
|
|
174
|
+
const root = `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/artifacts`;
|
|
175
|
+
return name ? `${root}?name=${encodeURIComponent(name)}` : root;
|
|
176
|
+
}
|
|
177
|
+
function assetsUrl(base) {
|
|
178
|
+
return `${stripTrailingSlash(base)}/api/v1/assets`;
|
|
179
|
+
}
|
|
180
|
+
function meUrl(base) {
|
|
181
|
+
return `${stripTrailingSlash(base)}/api/v1/me`;
|
|
182
|
+
}
|
|
183
|
+
function projectsUrl(base) {
|
|
184
|
+
return `${stripTrailingSlash(base)}/api/v1/projects`;
|
|
185
|
+
}
|
|
186
|
+
function projectVideosUrl(base, projectId) {
|
|
187
|
+
return `${stripTrailingSlash(base)}/api/v1/projects/${encodeURIComponent(projectId)}/videos`;
|
|
188
|
+
}
|
|
189
|
+
function runUrl(base, runId) {
|
|
190
|
+
return `${stripTrailingSlash(base)}/api/v1/runs/${encodeURIComponent(runId)}`;
|
|
191
|
+
}
|
|
192
|
+
function videoUrl(base, videoId, tail) {
|
|
193
|
+
return `${stripTrailingSlash(base)}/api/v1/videos/${encodeURIComponent(videoId)}/${tail}`;
|
|
194
|
+
}
|
|
195
|
+
function filesUrl(base, videoId, path) {
|
|
196
|
+
const root = videoUrl(base, videoId, "files");
|
|
197
|
+
return path !== void 0 ? `${root}?path=${encodeURIComponent(path)}` : root;
|
|
198
|
+
}
|
|
199
|
+
function transcribeUrl(base, videoId) {
|
|
200
|
+
return videoUrl(base, videoId, "transcribe");
|
|
201
|
+
}
|
|
202
|
+
function compileUrl(base, videoId) {
|
|
203
|
+
return videoUrl(base, videoId, "compile");
|
|
204
|
+
}
|
|
205
|
+
function resolveUrl(base, videoId) {
|
|
206
|
+
return videoUrl(base, videoId, "resolve");
|
|
207
|
+
}
|
|
208
|
+
function verifyUrl(base, videoId) {
|
|
209
|
+
return videoUrl(base, videoId, "verify");
|
|
210
|
+
}
|
|
211
|
+
function composeUrl(base, videoId) {
|
|
212
|
+
return videoUrl(base, videoId, "compose");
|
|
213
|
+
}
|
|
214
|
+
function asRecord(data, ctx) {
|
|
215
|
+
if (data === null || typeof data !== "object") throw new Error(`unexpected ${ctx} response: ${JSON.stringify(data)}`);
|
|
216
|
+
return data;
|
|
217
|
+
}
|
|
218
|
+
function parseRenderCreate(data) {
|
|
219
|
+
const o = asRecord(data, "render-create");
|
|
220
|
+
const renderId = o.render_id;
|
|
221
|
+
if (typeof renderId !== "string" || renderId === "") throw new Error(`render response missing render_id: ${JSON.stringify(data)}`);
|
|
222
|
+
return { renderId, status: o.status ?? "pending" };
|
|
223
|
+
}
|
|
224
|
+
function parseRenderState(data) {
|
|
225
|
+
const o = asRecord(data, "render-status");
|
|
226
|
+
const status = o.status;
|
|
227
|
+
if (typeof status !== "string") throw new Error(`render status response missing status: ${JSON.stringify(data)}`);
|
|
228
|
+
return {
|
|
229
|
+
status,
|
|
230
|
+
videoUrl: typeof o.video_url === "string" ? o.video_url : void 0,
|
|
231
|
+
error: o.error
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function isTerminalStatus(s) {
|
|
235
|
+
return s === "completed" || s === "failed";
|
|
236
|
+
}
|
|
237
|
+
function parseArtifactList(data) {
|
|
238
|
+
const o = asRecord(data, "artifact-list");
|
|
239
|
+
const arr = o.artifacts;
|
|
240
|
+
if (!Array.isArray(arr)) throw new Error(`artifact list response missing artifacts[]: ${JSON.stringify(data)}`);
|
|
241
|
+
return arr.filter((n) => typeof n === "string");
|
|
242
|
+
}
|
|
243
|
+
function parseArtifactContent(data) {
|
|
244
|
+
const o = asRecord(data, "artifact");
|
|
245
|
+
const name = o.name;
|
|
246
|
+
if (typeof name !== "string") throw new Error(`artifact response missing name: ${JSON.stringify(data)}`);
|
|
247
|
+
return { name, content: o.content };
|
|
248
|
+
}
|
|
249
|
+
function parseAssetCreate(data) {
|
|
250
|
+
const o = asRecord(data, "asset-create");
|
|
251
|
+
const assetId = o.asset_id;
|
|
252
|
+
const uploadUrl = o.upload_url;
|
|
253
|
+
if (typeof assetId !== "string" || assetId === "") {
|
|
254
|
+
throw new Error(`asset create response missing asset_id: ${JSON.stringify(data)}`);
|
|
255
|
+
}
|
|
256
|
+
if (typeof uploadUrl !== "string" || uploadUrl === "") {
|
|
257
|
+
throw new Error(`asset create response missing upload_url: ${JSON.stringify(data)}`);
|
|
258
|
+
}
|
|
259
|
+
return { assetId, uploadUrl };
|
|
260
|
+
}
|
|
261
|
+
function parseQaFrames(data) {
|
|
262
|
+
const o = asRecord(data, "qa-frames");
|
|
263
|
+
if (typeof o.url !== "string") throw new Error(`qa frames response missing url: ${JSON.stringify(data)}`);
|
|
264
|
+
return {
|
|
265
|
+
url: o.url,
|
|
266
|
+
width: Number(o.width ?? 0),
|
|
267
|
+
height: Number(o.height ?? 0),
|
|
268
|
+
frames: Number(o.frames ?? 0),
|
|
269
|
+
tStart: Number(o.tStart ?? 0),
|
|
270
|
+
tEnd: Number(o.tEnd ?? 0)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function parseQaWaveform(data) {
|
|
274
|
+
const o = asRecord(data, "qa-waveform");
|
|
275
|
+
if (typeof o.url !== "string") throw new Error(`qa waveform response missing url: ${JSON.stringify(data)}`);
|
|
276
|
+
const words = Array.isArray(o.words) ? o.words.map((w) => {
|
|
277
|
+
const r = w;
|
|
278
|
+
return { t: Number(r.t ?? 0), end: Number(r.end ?? 0), text: String(r.text ?? "") };
|
|
279
|
+
}) : [];
|
|
280
|
+
return { url: o.url, words };
|
|
281
|
+
}
|
|
282
|
+
function parseFileList(data) {
|
|
283
|
+
const o = asRecord(data, "file-list");
|
|
284
|
+
const arr = o.files;
|
|
285
|
+
if (!Array.isArray(arr)) throw new Error(`file list response missing files[]: ${JSON.stringify(data)}`);
|
|
286
|
+
return arr.filter((f) => f !== null && typeof f === "object").map((f) => ({
|
|
287
|
+
path: String(f.path ?? ""),
|
|
288
|
+
backend: String(f.backend ?? ""),
|
|
289
|
+
content_type: typeof f.content_type === "string" ? f.content_type : null,
|
|
290
|
+
bytes: typeof f.bytes === "number" ? f.bytes : null,
|
|
291
|
+
sha256: typeof f.sha256 === "string" ? f.sha256 : null,
|
|
292
|
+
status: typeof f.status === "string" ? f.status : null,
|
|
293
|
+
updated_at: typeof f.updated_at === "string" ? f.updated_at : null,
|
|
294
|
+
updated_by: typeof f.updated_by === "string" ? f.updated_by : null
|
|
295
|
+
}));
|
|
296
|
+
}
|
|
297
|
+
function unwrapFileEnvelope(data, what) {
|
|
298
|
+
const o = asRecord(data, what);
|
|
299
|
+
const f = o.file;
|
|
300
|
+
return f && typeof f === "object" && !Array.isArray(f) ? { ...o, ...f } : o;
|
|
301
|
+
}
|
|
302
|
+
function parseFileContent(data) {
|
|
303
|
+
const o = unwrapFileEnvelope(data, "file");
|
|
304
|
+
if (typeof o.path !== "string") throw new Error(`file response missing path: ${JSON.stringify(data)}`);
|
|
305
|
+
const content = typeof o.content === "string" ? o.content : void 0;
|
|
306
|
+
const downloadUrl = typeof o.download_url === "string" ? o.download_url : typeof o.url === "string" ? o.url : void 0;
|
|
307
|
+
if (content === void 0 && downloadUrl === void 0) {
|
|
308
|
+
throw new Error(`file response has neither content nor download_url: ${o.path}`);
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
path: o.path,
|
|
312
|
+
content,
|
|
313
|
+
downloadUrl,
|
|
314
|
+
sha256: typeof o.sha256 === "string" ? o.sha256 : null,
|
|
315
|
+
contentType: typeof o.content_type === "string" ? o.content_type : null
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function parseFilePresign(data) {
|
|
319
|
+
const o = unwrapFileEnvelope(data, "file-presign");
|
|
320
|
+
if (typeof o.upload_url !== "string" || o.upload_url === "") {
|
|
321
|
+
throw new Error(`presign response missing upload_url: ${JSON.stringify(data)}`);
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
uploadUrl: o.upload_url,
|
|
325
|
+
token: typeof o.token === "string" ? o.token : null,
|
|
326
|
+
expiresAt: typeof o.expires_at === "string" ? o.expires_at : null
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
function parseRunState(data) {
|
|
330
|
+
const o = asRecord(data, "run");
|
|
331
|
+
if (typeof o.status !== "string") throw new Error(`run response missing status: ${JSON.stringify(data)}`);
|
|
332
|
+
const events = Array.isArray(o.events) ? o.events.filter((e) => e !== null && typeof e === "object").map((e) => ({ ts: String(e.ts ?? ""), type: String(e.type ?? ""), payload: e.payload })) : [];
|
|
333
|
+
return {
|
|
334
|
+
id: String(o.id ?? ""),
|
|
335
|
+
status: o.status,
|
|
336
|
+
errorCode: typeof o.error_code === "string" ? o.error_code : null,
|
|
337
|
+
errorMessage: typeof o.error_message === "string" ? o.error_message : null,
|
|
338
|
+
events
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
function isTerminalRunStatus(s) {
|
|
342
|
+
return s === "completed" || s === "failed" || s === "timed_out";
|
|
343
|
+
}
|
|
344
|
+
function numOrNull(v) {
|
|
345
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
346
|
+
}
|
|
347
|
+
function strOrNull(v) {
|
|
348
|
+
return typeof v === "string" ? v : null;
|
|
349
|
+
}
|
|
350
|
+
function parseReviewSession(data) {
|
|
351
|
+
const o = asRecord(data, "review-session");
|
|
352
|
+
const videoId = o.video_id;
|
|
353
|
+
if (typeof videoId !== "string" || videoId === "") {
|
|
354
|
+
throw new Error(`review session response missing video_id: ${JSON.stringify(data)}`);
|
|
355
|
+
}
|
|
356
|
+
let activeRound = null;
|
|
357
|
+
if (o.active_round !== null && o.active_round !== void 0) {
|
|
358
|
+
const r = asRecord(o.active_round, "review-session active_round");
|
|
359
|
+
const roundId = r.round_id;
|
|
360
|
+
const runId = r.run_id;
|
|
361
|
+
if (typeof roundId !== "string" || typeof runId !== "string") {
|
|
362
|
+
throw new Error(`review session active_round missing round_id/run_id: ${JSON.stringify(o.active_round)}`);
|
|
363
|
+
}
|
|
364
|
+
const changes = Array.isArray(r.changes) ? r.changes.filter((c) => c !== null && typeof c === "object").map((c) => ({
|
|
365
|
+
id: String(c.id ?? ""),
|
|
366
|
+
idx: Number(c.idx ?? 0),
|
|
367
|
+
kind: String(c.kind ?? ""),
|
|
368
|
+
t: numOrNull(c.t),
|
|
369
|
+
t2: numOrNull(c.t2),
|
|
370
|
+
x: numOrNull(c.x),
|
|
371
|
+
y: numOrNull(c.y),
|
|
372
|
+
text: strOrNull(c.text),
|
|
373
|
+
outcome: strOrNull(c.outcome)
|
|
374
|
+
})) : [];
|
|
375
|
+
activeRound = {
|
|
376
|
+
roundId,
|
|
377
|
+
runId,
|
|
378
|
+
seq: Number(r.seq ?? 0),
|
|
379
|
+
mode: String(r.mode ?? ""),
|
|
380
|
+
status: String(r.status ?? ""),
|
|
381
|
+
instruction: typeof r.instruction === "string" ? r.instruction : "",
|
|
382
|
+
changes
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
videoId,
|
|
387
|
+
title: strOrNull(o.title),
|
|
388
|
+
latestVersionSeq: numOrNull(o.latest_version_seq),
|
|
389
|
+
activeRound,
|
|
390
|
+
timeline: o.timeline ?? null,
|
|
391
|
+
sourceUrl: strOrNull(o.source_url)
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
var CloudHttpError = class extends Error {
|
|
395
|
+
constructor(status, message, body) {
|
|
396
|
+
super(message);
|
|
397
|
+
this.status = status;
|
|
398
|
+
this.body = body;
|
|
399
|
+
this.name = "CloudHttpError";
|
|
400
|
+
}
|
|
401
|
+
status;
|
|
402
|
+
body;
|
|
403
|
+
};
|
|
404
|
+
function extractErrorMessage(data) {
|
|
405
|
+
if (typeof data === "string" && data.trim() !== "") return data;
|
|
406
|
+
if (data && typeof data === "object") {
|
|
407
|
+
const o = data;
|
|
408
|
+
if (typeof o.error === "string") return o.error;
|
|
409
|
+
if (typeof o.message === "string") return o.message;
|
|
410
|
+
if (o.error) return JSON.stringify(o.error);
|
|
411
|
+
}
|
|
412
|
+
return void 0;
|
|
413
|
+
}
|
|
414
|
+
var CloudClient = class {
|
|
415
|
+
constructor(cfg, opts = {}) {
|
|
416
|
+
this.cfg = cfg;
|
|
417
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
418
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
419
|
+
if (!this.fetchImpl) throw new Error("global fetch is unavailable \u2014 Node 18+ required, or inject fetchImpl");
|
|
420
|
+
}
|
|
421
|
+
cfg;
|
|
422
|
+
fetchImpl;
|
|
423
|
+
timeoutMs;
|
|
424
|
+
requireVideoId() {
|
|
425
|
+
if (!this.cfg.videoId) throw new Error("missing video id \u2014 pass --video-id or set VIDEO_ID");
|
|
426
|
+
return this.cfg.videoId;
|
|
427
|
+
}
|
|
428
|
+
async request(method, url, body) {
|
|
429
|
+
const ac = new AbortController();
|
|
430
|
+
const timer = setTimeout(() => ac.abort(), this.timeoutMs);
|
|
431
|
+
try {
|
|
432
|
+
const res = await this.fetchImpl(url, {
|
|
433
|
+
method,
|
|
434
|
+
headers: {
|
|
435
|
+
Authorization: `Bearer ${this.cfg.credential}`,
|
|
436
|
+
Accept: "application/json",
|
|
437
|
+
...body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
438
|
+
},
|
|
439
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0,
|
|
440
|
+
signal: ac.signal
|
|
441
|
+
});
|
|
442
|
+
const text = await res.text();
|
|
443
|
+
let data = void 0;
|
|
444
|
+
if (text) {
|
|
445
|
+
try {
|
|
446
|
+
data = JSON.parse(text);
|
|
447
|
+
} catch {
|
|
448
|
+
data = text;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (!res.ok) {
|
|
452
|
+
const detail = extractErrorMessage(data) ?? `${res.status} ${res.statusText}`;
|
|
453
|
+
throw new CloudHttpError(res.status, `${method} ${url} failed: ${detail}`, data);
|
|
454
|
+
}
|
|
455
|
+
return data;
|
|
456
|
+
} catch (err) {
|
|
457
|
+
if (err instanceof CloudHttpError) throw err;
|
|
458
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
459
|
+
throw new Error(`request timed out after ${this.timeoutMs}ms: ${method} ${url}`);
|
|
460
|
+
}
|
|
461
|
+
throw err;
|
|
462
|
+
} finally {
|
|
463
|
+
clearTimeout(timer);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// ---- render -------------------------------------------------------------
|
|
467
|
+
async createRender(mode) {
|
|
468
|
+
const data = await this.request("POST", rendersUrl(this.cfg.base), {
|
|
469
|
+
video_id: this.requireVideoId(),
|
|
470
|
+
mode
|
|
471
|
+
});
|
|
472
|
+
return parseRenderCreate(data);
|
|
473
|
+
}
|
|
474
|
+
async getRender(renderId) {
|
|
475
|
+
const data = await this.request("GET", renderUrl(this.cfg.base, renderId));
|
|
476
|
+
return parseRenderState(data);
|
|
477
|
+
}
|
|
478
|
+
// ---- qa -----------------------------------------------------------------
|
|
479
|
+
async qaFrames(args) {
|
|
480
|
+
const body = { kind: "frames", start: args.start, end: args.end };
|
|
481
|
+
if (args.cols !== void 0) body.cols = args.cols;
|
|
482
|
+
if (args.interval !== void 0) body.interval = args.interval;
|
|
483
|
+
const data = await this.request("POST", qaUrl(this.cfg.base, this.requireVideoId()), body);
|
|
484
|
+
return parseQaFrames(data);
|
|
485
|
+
}
|
|
486
|
+
async qaWaveform(args) {
|
|
487
|
+
const data = await this.request("POST", qaUrl(this.cfg.base, this.requireVideoId()), {
|
|
488
|
+
kind: "waveform",
|
|
489
|
+
start: args.start,
|
|
490
|
+
end: args.end
|
|
491
|
+
});
|
|
492
|
+
return parseQaWaveform(data);
|
|
493
|
+
}
|
|
494
|
+
// ---- artifacts ----------------------------------------------------------
|
|
495
|
+
async listArtifacts() {
|
|
496
|
+
const data = await this.request("GET", artifactsUrl(this.cfg.base, this.requireVideoId()));
|
|
497
|
+
return parseArtifactList(data);
|
|
498
|
+
}
|
|
499
|
+
async getArtifact(name) {
|
|
500
|
+
const data = await this.request("GET", artifactsUrl(this.cfg.base, this.requireVideoId(), name));
|
|
501
|
+
return parseArtifactContent(data);
|
|
502
|
+
}
|
|
503
|
+
// ---- assets -------------------------------------------------------------
|
|
504
|
+
/** POST /api/v1/assets: request an asset row + presigned upload URL
|
|
505
|
+
* (clipready assets route: {kind, content_type?, video_id?, filename?} →
|
|
506
|
+
* {asset_id, upload_url, upload_token, expires_at}). */
|
|
507
|
+
async createAsset(args) {
|
|
508
|
+
const body = { kind: args.kind, video_id: this.requireVideoId() };
|
|
509
|
+
if (args.contentType !== void 0) body.content_type = args.contentType;
|
|
510
|
+
if (args.filename !== void 0) body.filename = args.filename;
|
|
511
|
+
const data = await this.request("POST", assetsUrl(this.cfg.base), body);
|
|
512
|
+
return parseAssetCreate(data);
|
|
513
|
+
}
|
|
514
|
+
/** PUT raw bytes to a presigned upload URL. No Authorization header — the
|
|
515
|
+
* signed URL carries its own credentials and may be a different host. */
|
|
516
|
+
async upload(url, bytes, contentType, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
517
|
+
const ac = new AbortController();
|
|
518
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
519
|
+
try {
|
|
520
|
+
const res = await this.fetchImpl(url, {
|
|
521
|
+
method: "PUT",
|
|
522
|
+
headers: { "Content-Type": contentType },
|
|
523
|
+
body: bytes,
|
|
524
|
+
signal: ac.signal
|
|
525
|
+
});
|
|
526
|
+
if (!res.ok) {
|
|
527
|
+
throw new Error(`asset upload failed: HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
531
|
+
throw new Error(`asset upload timed out after ${timeoutMs}ms: ${url}`);
|
|
532
|
+
}
|
|
533
|
+
throw err;
|
|
534
|
+
} finally {
|
|
535
|
+
clearTimeout(timer);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// ---- review session -----------------------------------------------------
|
|
539
|
+
async getReview() {
|
|
540
|
+
const data = await this.request("GET", reviewUrl(this.cfg.base, this.requireVideoId()));
|
|
541
|
+
return parseReviewSession(data);
|
|
542
|
+
}
|
|
543
|
+
/** POST a review-round event ({run_id, type, payload?}); expects {ok:true}. */
|
|
544
|
+
async pushReview(body) {
|
|
545
|
+
const data = await this.request("POST", reviewUrl(this.cfg.base, this.requireVideoId()), body);
|
|
546
|
+
const o = data;
|
|
547
|
+
if (!o || o.ok !== true) {
|
|
548
|
+
throw new Error(`review push (${body.type}) did not return {ok:true}: ${JSON.stringify(data)}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// ---- auth gate ----------------------------------------------------------
|
|
552
|
+
/** GET /v1/me — validates the credential; returns the raw payload. */
|
|
553
|
+
async me() {
|
|
554
|
+
const data = await this.request("GET", meUrl(this.cfg.base));
|
|
555
|
+
const o = data;
|
|
556
|
+
if (!o || typeof o !== "object" || typeof o.user_id !== "string") {
|
|
557
|
+
throw new Error(`/v1/me did not return a user: ${JSON.stringify(data)}`);
|
|
558
|
+
}
|
|
559
|
+
return o;
|
|
560
|
+
}
|
|
561
|
+
// ---- projects / videos (init flow) --------------------------------------
|
|
562
|
+
async createProject(title) {
|
|
563
|
+
const data = await this.request("POST", projectsUrl(this.cfg.base), { title });
|
|
564
|
+
const o = asRecord(data, "project-create");
|
|
565
|
+
const projectId = o.project_id;
|
|
566
|
+
if (typeof projectId !== "string" || projectId === "") {
|
|
567
|
+
throw new Error(`project create response missing project_id: ${JSON.stringify(data)}`);
|
|
568
|
+
}
|
|
569
|
+
return { projectId };
|
|
570
|
+
}
|
|
571
|
+
/** POST /v1/assets for a NEW source (no video yet): {kind:"source", ...}. */
|
|
572
|
+
async createSourceAsset(args) {
|
|
573
|
+
const body = { kind: "source", content_type: args.contentType };
|
|
574
|
+
if (args.filename !== void 0) body.filename = args.filename;
|
|
575
|
+
if (args.videoId !== void 0) body.video_id = args.videoId;
|
|
576
|
+
const data = await this.request("POST", assetsUrl(this.cfg.base), body);
|
|
577
|
+
return parseAssetCreate(data);
|
|
578
|
+
}
|
|
579
|
+
/** POST /v1/projects/{id}/videos {asset_id, title?, workflow:"cli"}. */
|
|
580
|
+
async createVideo(projectId, args) {
|
|
581
|
+
const body = { asset_id: args.assetId, workflow: "cli" };
|
|
582
|
+
if (args.title !== void 0) body.title = args.title;
|
|
583
|
+
const data = await this.request("POST", projectVideosUrl(this.cfg.base, projectId), body);
|
|
584
|
+
const o = asRecord(data, "video-create");
|
|
585
|
+
const videoId = o.video_id;
|
|
586
|
+
if (typeof videoId !== "string" || videoId === "") {
|
|
587
|
+
throw new Error(`video create response missing video_id: ${JSON.stringify(data)}`);
|
|
588
|
+
}
|
|
589
|
+
return { videoId, projectId: typeof o.project_id === "string" ? o.project_id : projectId };
|
|
590
|
+
}
|
|
591
|
+
// ---- virtual file system -------------------------------------------------
|
|
592
|
+
async filesList() {
|
|
593
|
+
const data = await this.request("GET", filesUrl(this.cfg.base, this.requireVideoId()));
|
|
594
|
+
return parseFileList(data);
|
|
595
|
+
}
|
|
596
|
+
async filesGet(path) {
|
|
597
|
+
const data = await this.request("GET", filesUrl(this.cfg.base, this.requireVideoId(), path));
|
|
598
|
+
return parseFileContent(data);
|
|
599
|
+
}
|
|
600
|
+
/** Inline PUT (≤5MB text payloads) → {path, sha256, bytes}. */
|
|
601
|
+
async filesPutInline(path, content, contentType) {
|
|
602
|
+
const body = { path, content };
|
|
603
|
+
if (contentType !== void 0) body.content_type = contentType;
|
|
604
|
+
const data = await this.request("PUT", filesUrl(this.cfg.base, this.requireVideoId()), body);
|
|
605
|
+
const o = unwrapFileEnvelope(data, "file-put");
|
|
606
|
+
return {
|
|
607
|
+
path: typeof o.path === "string" ? o.path : path,
|
|
608
|
+
sha256: typeof o.sha256 === "string" ? o.sha256 : null,
|
|
609
|
+
bytes: typeof o.bytes === "number" ? o.bytes : null
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/** Presign PUT for large/binary payloads → {upload_url, token, expires_at}. */
|
|
613
|
+
async filesPresign(args) {
|
|
614
|
+
const data = await this.request("PUT", filesUrl(this.cfg.base, this.requireVideoId()), {
|
|
615
|
+
path: args.path,
|
|
616
|
+
content_type: args.contentType,
|
|
617
|
+
bytes: args.bytes,
|
|
618
|
+
sha256: args.sha256,
|
|
619
|
+
presign: true
|
|
620
|
+
});
|
|
621
|
+
return parseFilePresign(data);
|
|
622
|
+
}
|
|
623
|
+
/** Commit a presigned upload → {path, status:"ready"}. */
|
|
624
|
+
async filesCommit(path) {
|
|
625
|
+
const data = await this.request("PUT", filesUrl(this.cfg.base, this.requireVideoId()), { path, commit: true });
|
|
626
|
+
const o = unwrapFileEnvelope(data, "file-commit");
|
|
627
|
+
if (o.status !== "ready") {
|
|
628
|
+
throw new Error(`file commit for ${path} did not return status "ready": ${JSON.stringify(data)}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async filesDelete(path) {
|
|
632
|
+
await this.request("DELETE", filesUrl(this.cfg.base, this.requireVideoId(), path));
|
|
633
|
+
}
|
|
634
|
+
// ---- server-side pipeline verbs -----------------------------------------
|
|
635
|
+
/** POST /videos/{id}/transcribe → 202 {run_id}. */
|
|
636
|
+
async transcribeStart(body) {
|
|
637
|
+
const data = await this.request("POST", transcribeUrl(this.cfg.base, this.requireVideoId()), body);
|
|
638
|
+
const o = asRecord(data, "transcribe");
|
|
639
|
+
if (typeof o.run_id !== "string" || o.run_id === "") {
|
|
640
|
+
throw new Error(`transcribe response missing run_id: ${JSON.stringify(data)}`);
|
|
641
|
+
}
|
|
642
|
+
return { runId: o.run_id };
|
|
643
|
+
}
|
|
644
|
+
/** POST /videos/{id}/verify → 202 {run_id}. */
|
|
645
|
+
async verifyStart(body) {
|
|
646
|
+
const data = await this.request("POST", verifyUrl(this.cfg.base, this.requireVideoId()), body);
|
|
647
|
+
const o = asRecord(data, "verify");
|
|
648
|
+
if (typeof o.run_id !== "string" || o.run_id === "") {
|
|
649
|
+
throw new Error(`verify response missing run_id: ${JSON.stringify(data)}`);
|
|
650
|
+
}
|
|
651
|
+
return { runId: o.run_id };
|
|
652
|
+
}
|
|
653
|
+
/** GET /runs/{id} — run status + events. */
|
|
654
|
+
async getRun(runId) {
|
|
655
|
+
const data = await this.request("GET", runUrl(this.cfg.base, runId));
|
|
656
|
+
return parseRunState(data);
|
|
657
|
+
}
|
|
658
|
+
/** POST /videos/{id}/compile → {ok, plan, legacy_edl, diagnostics, uses}. */
|
|
659
|
+
async compileRemote(body) {
|
|
660
|
+
const data = await this.request("POST", compileUrl(this.cfg.base, this.requireVideoId()), body);
|
|
661
|
+
return asRecord(data, "compile");
|
|
662
|
+
}
|
|
663
|
+
/** POST /videos/{id}/resolve {phrase, source?, occurrence?}. */
|
|
664
|
+
async resolveRemote(body) {
|
|
665
|
+
const data = await this.request("POST", resolveUrl(this.cfg.base, this.requireVideoId()), body);
|
|
666
|
+
return asRecord(data, "resolve");
|
|
667
|
+
}
|
|
668
|
+
/** GET /videos/{id}/compose → {html, counts, media:[{src, role}]}. */
|
|
669
|
+
async getCompose() {
|
|
670
|
+
const data = await this.request("GET", composeUrl(this.cfg.base, this.requireVideoId()));
|
|
671
|
+
const o = asRecord(data, "compose");
|
|
672
|
+
if (typeof o.html !== "string") throw new Error(`compose response missing html: ${JSON.stringify(data)}`);
|
|
673
|
+
const media = Array.isArray(o.media) ? o.media.filter((m) => m !== null && typeof m === "object").map((m) => ({ src: String(m.src ?? ""), role: String(m.role ?? "") })) : [];
|
|
674
|
+
const counts = o.counts !== null && typeof o.counts === "object" ? o.counts : {};
|
|
675
|
+
return { html: o.html, counts, media };
|
|
676
|
+
}
|
|
677
|
+
// ---- download (signed URLs; no auth header) -----------------------------
|
|
678
|
+
async download(url, dest, timeoutMs = DEFAULT_DOWNLOAD_TIMEOUT_MS) {
|
|
679
|
+
return downloadTo(url, dest, { fetchImpl: this.fetchImpl, timeoutMs });
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
async function downloadTo(url, dest, opts = {}) {
|
|
683
|
+
const f = opts.fetchImpl ?? globalThis.fetch;
|
|
684
|
+
const ac = new AbortController();
|
|
685
|
+
const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS);
|
|
686
|
+
try {
|
|
687
|
+
const res = await f(url, { signal: ac.signal });
|
|
688
|
+
if (!res.ok) throw new Error(`download failed: HTTP ${res.status} ${res.statusText} for ${url}`);
|
|
689
|
+
mkdirSync2(dirname2(dest), { recursive: true });
|
|
690
|
+
const body = res.body;
|
|
691
|
+
if (!body) {
|
|
692
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
693
|
+
writeFileSync(dest, buf);
|
|
694
|
+
return { path: dest, bytes: buf.length };
|
|
695
|
+
}
|
|
696
|
+
await pipeline(Readable.fromWeb(body), createWriteStream2(dest));
|
|
697
|
+
return { path: dest, bytes: statSync(dest).size };
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
700
|
+
throw new Error(`download timed out after ${opts.timeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS}ms: ${url}`);
|
|
701
|
+
}
|
|
702
|
+
throw err;
|
|
703
|
+
} finally {
|
|
704
|
+
clearTimeout(timer);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// src/cloud/render.ts
|
|
709
|
+
import { join } from "path";
|
|
710
|
+
var MAX_POLL_MS = 30 * 60 * 1e3;
|
|
711
|
+
function defaultRenderOut(jobDir, mode) {
|
|
712
|
+
return join(jobDir, mode === "final" ? "final.mp4" : "preview.mp4");
|
|
713
|
+
}
|
|
714
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
715
|
+
async function pollRender(client, renderId, opts = {}) {
|
|
716
|
+
const intervalMs = opts.intervalMs ?? 1e4;
|
|
717
|
+
const maxMs = opts.maxMs ?? MAX_POLL_MS;
|
|
718
|
+
const nap = opts.sleepImpl ?? sleep;
|
|
719
|
+
const now = opts.now ?? Date.now;
|
|
720
|
+
const start = now();
|
|
721
|
+
for (; ; ) {
|
|
722
|
+
const state = await client.getRender(renderId);
|
|
723
|
+
const elapsed = now() - start;
|
|
724
|
+
opts.onTick?.(state, elapsed);
|
|
725
|
+
if (isTerminalStatus(state.status)) return state;
|
|
726
|
+
if (elapsed + intervalMs >= maxMs) {
|
|
727
|
+
throw new Error(`render ${renderId} did not finish within ${Math.round(maxMs / 6e4)} min (last status: ${state.status})`);
|
|
728
|
+
}
|
|
729
|
+
await nap(intervalMs);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/cloud/skill.ts
|
|
734
|
+
import { cpSync, existsSync, readdirSync, rmSync, statSync as statSync2 } from "fs";
|
|
735
|
+
import { homedir } from "os";
|
|
736
|
+
import { dirname as dirname3, join as join2, relative, resolve } from "path";
|
|
737
|
+
import { fileURLToPath } from "url";
|
|
738
|
+
function bundledSkillDir() {
|
|
739
|
+
const here = dirname3(fileURLToPath(import.meta.url));
|
|
740
|
+
const candidates = [
|
|
741
|
+
join2(here, "skills", "video-editing"),
|
|
742
|
+
join2(here, "..", "skills", "video-editing"),
|
|
743
|
+
join2(here, "..", "..", "skills", "video-editing")
|
|
744
|
+
];
|
|
745
|
+
for (const c of candidates) {
|
|
746
|
+
if (existsSync(join2(c, "SKILL.md"))) return resolve(c);
|
|
747
|
+
}
|
|
748
|
+
throw new Error(`bundled skill not found (looked in: ${candidates.map((c) => resolve(c)).join(", ")})`);
|
|
749
|
+
}
|
|
750
|
+
function defaultSkillTarget() {
|
|
751
|
+
return join2(homedir(), ".claude", "skills");
|
|
752
|
+
}
|
|
753
|
+
function listFilesRecursive(root) {
|
|
754
|
+
const out = [];
|
|
755
|
+
for (const entry of readdirSync(root)) {
|
|
756
|
+
const full = join2(root, entry);
|
|
757
|
+
if (statSync2(full).isDirectory()) out.push(...listFilesRecursive(full));
|
|
758
|
+
else out.push(full);
|
|
759
|
+
}
|
|
760
|
+
return out;
|
|
761
|
+
}
|
|
762
|
+
function installSkill(opts = {}) {
|
|
763
|
+
const source = bundledSkillDir();
|
|
764
|
+
const targetRoot = opts.target ? resolve(opts.target) : defaultSkillTarget();
|
|
765
|
+
const dest = join2(targetRoot, "video-editing");
|
|
766
|
+
rmSync(dest, { recursive: true, force: true });
|
|
767
|
+
cpSync(source, dest, { recursive: true });
|
|
768
|
+
const files = listFilesRecursive(dest).map((f) => relative(dest, f)).sort();
|
|
769
|
+
return { source, dest, files };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/cloud/jobfile.ts
|
|
773
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
774
|
+
import { join as join3 } from "path";
|
|
775
|
+
function jobJsonPath(jobDir) {
|
|
776
|
+
return join3(jobDir, "job.json");
|
|
777
|
+
}
|
|
778
|
+
function writeJobFile(jobDir, job) {
|
|
779
|
+
mkdirSync3(jobDir, { recursive: true });
|
|
780
|
+
const path = jobJsonPath(jobDir);
|
|
781
|
+
writeFileSync2(path, JSON.stringify(job, null, 2) + "\n", "utf8");
|
|
782
|
+
return path;
|
|
783
|
+
}
|
|
784
|
+
function readJobFile(jobDir) {
|
|
785
|
+
const path = jobJsonPath(jobDir);
|
|
786
|
+
if (!existsSync2(path)) return null;
|
|
787
|
+
try {
|
|
788
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
789
|
+
if (typeof raw.video_id !== "string" || raw.video_id === "") return null;
|
|
790
|
+
return {
|
|
791
|
+
video_id: raw.video_id,
|
|
792
|
+
project_id: typeof raw.project_id === "string" ? raw.project_id : "",
|
|
793
|
+
api_base: typeof raw.api_base === "string" ? raw.api_base : "",
|
|
794
|
+
stem: typeof raw.stem === "string" ? raw.stem : "source",
|
|
795
|
+
duration_s: typeof raw.duration_s === "number" ? raw.duration_s : 0
|
|
796
|
+
};
|
|
797
|
+
} catch {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function videoIdFromJob(jobDir, resolved) {
|
|
802
|
+
if (resolved) return resolved;
|
|
803
|
+
const job = readJobFile(jobDir);
|
|
804
|
+
if (job) return job.video_id;
|
|
805
|
+
throw new Error(`missing video id \u2014 pass --video-id, set VIDEO_ID, or run \`init\` first (no ${jobJsonPath(jobDir)})`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/cloud/init.ts
|
|
809
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync2 } from "fs";
|
|
810
|
+
import { join as join5, parse as parsePath, resolve as resolvePath } from "path";
|
|
811
|
+
|
|
812
|
+
// src/master.ts
|
|
813
|
+
import { writeFileSync as writeFileSync3 } from "fs";
|
|
814
|
+
import { existsSync as existsSync3 } from "fs";
|
|
815
|
+
import { join as join4 } from "path";
|
|
816
|
+
|
|
817
|
+
// src/probe.ts
|
|
818
|
+
function rotationDegrees(stream) {
|
|
819
|
+
for (const sideData of stream.side_data_list ?? []) {
|
|
820
|
+
if (sideData && "rotation" in sideData) {
|
|
821
|
+
const v = Number(sideData.rotation);
|
|
822
|
+
return Number.isFinite(v) ? Math.trunc(v) : 0;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const tags = stream.tags ?? {};
|
|
826
|
+
if ("rotate" in tags) {
|
|
827
|
+
const v = Number(tags.rotate);
|
|
828
|
+
return Number.isFinite(v) ? Math.trunc(v) : 0;
|
|
829
|
+
}
|
|
830
|
+
return 0;
|
|
831
|
+
}
|
|
832
|
+
function displayedDimensions(stream) {
|
|
833
|
+
const width = Math.trunc(Number(stream.width ?? 0)) || 0;
|
|
834
|
+
const height = Math.trunc(Number(stream.height ?? 0)) || 0;
|
|
835
|
+
const rotation = rotationDegrees(stream);
|
|
836
|
+
if (Math.abs(rotation) % 180 === 90) {
|
|
837
|
+
return [height, width, rotation];
|
|
838
|
+
}
|
|
839
|
+
return [width, height, rotation];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// src/util.ts
|
|
843
|
+
function isoNow() {
|
|
844
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "+00:00");
|
|
845
|
+
}
|
|
846
|
+
function pyFixed(x, prec) {
|
|
847
|
+
if (!Number.isFinite(x)) return String(x);
|
|
848
|
+
const neg = x < 0 || Object.is(x, -0);
|
|
849
|
+
const a = Math.abs(x);
|
|
850
|
+
const m = Math.pow(10, prec);
|
|
851
|
+
const scaled = a * m;
|
|
852
|
+
const floor = Math.floor(scaled);
|
|
853
|
+
const diff = scaled - floor;
|
|
854
|
+
const EPS2 = 1e-9;
|
|
855
|
+
let rounded;
|
|
856
|
+
if (Math.abs(diff - 0.5) < EPS2) {
|
|
857
|
+
rounded = floor % 2 === 0 ? floor : floor + 1;
|
|
858
|
+
} else {
|
|
859
|
+
rounded = Math.round(scaled);
|
|
860
|
+
}
|
|
861
|
+
let s = (rounded / m).toFixed(prec);
|
|
862
|
+
if (neg && Number(s) !== 0) s = "-" + s;
|
|
863
|
+
return s;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/master.ts
|
|
867
|
+
var X264_BAKE = [
|
|
868
|
+
"-c:v",
|
|
869
|
+
"libx264",
|
|
870
|
+
"-preset",
|
|
871
|
+
"medium",
|
|
872
|
+
"-crf",
|
|
873
|
+
"18",
|
|
874
|
+
"-pix_fmt",
|
|
875
|
+
"yuv420p",
|
|
876
|
+
"-c:a",
|
|
877
|
+
"aac",
|
|
878
|
+
"-b:a",
|
|
879
|
+
"192k",
|
|
880
|
+
"-movflags",
|
|
881
|
+
"+faststart"
|
|
882
|
+
];
|
|
883
|
+
async function ensureVerticalMaster(jobDir, source, opts = {}) {
|
|
884
|
+
const out = join4(jobDir, "vertical_src.mp4");
|
|
885
|
+
if (existsSync3(out) && !opts.force) return out;
|
|
886
|
+
const probe = await ffprobe(source);
|
|
887
|
+
const stream = videoStream(probe);
|
|
888
|
+
const [displayW, displayH, rotation] = displayedDimensions(stream);
|
|
889
|
+
const longEdge = Math.max(displayW, displayH);
|
|
890
|
+
let vf = displayH >= displayW && longEdge > 1920 ? "scale=-2:1920" : null;
|
|
891
|
+
if (displayW > displayH && longEdge > 1920) vf = "scale=1920:-2";
|
|
892
|
+
const cmd = ["ffmpeg", "-y", "-i", source];
|
|
893
|
+
if (vf) cmd.push("-vf", vf);
|
|
894
|
+
cmd.push(...X264_BAKE, out);
|
|
895
|
+
await run(cmd);
|
|
896
|
+
const outProbe = await ffprobe(out);
|
|
897
|
+
const outStream = videoStream(outProbe);
|
|
898
|
+
const outW = Math.trunc(Number(outStream.width ?? 0)) || 0;
|
|
899
|
+
const outH = Math.trunc(Number(outStream.height ?? 0)) || 0;
|
|
900
|
+
let retriedWith = null;
|
|
901
|
+
if (displayH > displayW && outW > outH) {
|
|
902
|
+
retriedWith = vf ? `transpose=1,${vf}` : "transpose=1";
|
|
903
|
+
await run(["ffmpeg", "-y", "-i", source, "-vf", retriedWith, ...X264_BAKE, out]);
|
|
904
|
+
}
|
|
905
|
+
writeFileSync3(
|
|
906
|
+
join4(jobDir, "master.json"),
|
|
907
|
+
JSON.stringify(
|
|
908
|
+
{
|
|
909
|
+
source,
|
|
910
|
+
master: out,
|
|
911
|
+
display_width: displayW,
|
|
912
|
+
display_height: displayH,
|
|
913
|
+
rotation,
|
|
914
|
+
scale_filter: vf,
|
|
915
|
+
retry_filter: retriedWith,
|
|
916
|
+
created: isoNow()
|
|
917
|
+
},
|
|
918
|
+
null,
|
|
919
|
+
2
|
|
920
|
+
) + "\n",
|
|
921
|
+
"utf8"
|
|
922
|
+
);
|
|
923
|
+
return out;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// src/cloud/init.ts
|
|
927
|
+
function contentTypeFor(path) {
|
|
928
|
+
const ext = parsePath(path).ext.toLowerCase();
|
|
929
|
+
const map = { ".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo" };
|
|
930
|
+
return map[ext] ?? "video/mp4";
|
|
931
|
+
}
|
|
932
|
+
async function initCloudJob(client, opts) {
|
|
933
|
+
const source = resolvePath(opts.source);
|
|
934
|
+
if (!existsSync4(source)) throw new Error(`source not found: ${source}`);
|
|
935
|
+
const stem = parsePath(source).name;
|
|
936
|
+
const jobDir = resolvePath(opts.jobDir ?? join5(process.cwd(), `job_${stem}`));
|
|
937
|
+
mkdirSync4(jobDir, { recursive: true });
|
|
938
|
+
const log = (line) => {
|
|
939
|
+
if (opts.verbose !== false) process.stderr.write(line + "\n");
|
|
940
|
+
};
|
|
941
|
+
await client.me();
|
|
942
|
+
const probe = await ffprobe(source);
|
|
943
|
+
const durationS = Number(probe.format?.duration ?? 0) || 0;
|
|
944
|
+
if (!(durationS > 0)) throw new Error(`could not read a duration from ${source} (is it a video?)`);
|
|
945
|
+
let uploadPath = source;
|
|
946
|
+
if (opts.master) {
|
|
947
|
+
log(" baking vertical 9:16 master (local ffmpeg)");
|
|
948
|
+
uploadPath = await ensureVerticalMaster(jobDir, source);
|
|
949
|
+
}
|
|
950
|
+
const title = opts.title ?? stem;
|
|
951
|
+
const { projectId } = await client.createProject(title);
|
|
952
|
+
log(` project: ${projectId}`);
|
|
953
|
+
const asset = await client.createSourceAsset({
|
|
954
|
+
contentType: contentTypeFor(uploadPath),
|
|
955
|
+
filename: parsePath(uploadPath).base
|
|
956
|
+
});
|
|
957
|
+
const bytes = readFileSync2(uploadPath);
|
|
958
|
+
log(` uploading ${parsePath(uploadPath).base} (${(bytes.length / (1024 * 1024)).toFixed(1)} MB)`);
|
|
959
|
+
await client.upload(asset.uploadUrl, bytes, contentTypeFor(uploadPath));
|
|
960
|
+
const video = await client.createVideo(projectId, { assetId: asset.assetId, title });
|
|
961
|
+
log(` video: ${video.videoId}`);
|
|
962
|
+
const job = {
|
|
963
|
+
video_id: video.videoId,
|
|
964
|
+
project_id: video.projectId,
|
|
965
|
+
api_base: client.cfg.base,
|
|
966
|
+
stem,
|
|
967
|
+
duration_s: durationS
|
|
968
|
+
};
|
|
969
|
+
const jobJson = writeJobFile(jobDir, job);
|
|
970
|
+
return {
|
|
971
|
+
jobDir,
|
|
972
|
+
jobJson,
|
|
973
|
+
videoId: video.videoId,
|
|
974
|
+
projectId: video.projectId,
|
|
975
|
+
stem,
|
|
976
|
+
durationS,
|
|
977
|
+
uploaded: uploadPath,
|
|
978
|
+
uploadedBytes: bytes.length
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// src/cloud/files.ts
|
|
983
|
+
import { createHash } from "crypto";
|
|
984
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync3, statSync as statSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
985
|
+
import { dirname as dirname4, join as join6, sep } from "path";
|
|
986
|
+
var INLINE_MAX_BYTES = 5 * 1024 * 1024;
|
|
987
|
+
function sha256Hex(bytes) {
|
|
988
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
989
|
+
}
|
|
990
|
+
function sameSha(a, b) {
|
|
991
|
+
if (!a || !b) return false;
|
|
992
|
+
const norm = (s) => s.replace(/^sha256:/, "").toLowerCase();
|
|
993
|
+
return norm(a) === norm(b);
|
|
994
|
+
}
|
|
995
|
+
function vfsLocalPath(jobDir, path) {
|
|
996
|
+
const parts = path.split("/").filter((p) => p !== "" && p !== "." && p !== "..");
|
|
997
|
+
if (parts.length === 0) throw new Error(`bad VFS path: ${JSON.stringify(path)}`);
|
|
998
|
+
return join6(jobDir, ...parts);
|
|
999
|
+
}
|
|
1000
|
+
function toVfsPath(jobDir, localPath) {
|
|
1001
|
+
const abs = localPath.startsWith(sep) ? localPath : join6(jobDir, localPath);
|
|
1002
|
+
const rel = abs.startsWith(jobDir + sep) ? abs.slice(jobDir.length + 1) : localPath;
|
|
1003
|
+
return rel.split(sep).join("/");
|
|
1004
|
+
}
|
|
1005
|
+
var TEXT_TYPES = /^(text\/|application\/(json|xml|javascript|x-ndjson))/;
|
|
1006
|
+
function vfsContentType(path) {
|
|
1007
|
+
const m = /\.[^./\\]+$/.exec(path.toLowerCase());
|
|
1008
|
+
const ext = m ? m[0] : "";
|
|
1009
|
+
const map = {
|
|
1010
|
+
".json": "application/json",
|
|
1011
|
+
".md": "text/markdown",
|
|
1012
|
+
".txt": "text/plain",
|
|
1013
|
+
".html": "text/html",
|
|
1014
|
+
".srt": "text/plain",
|
|
1015
|
+
".vtt": "text/vtt",
|
|
1016
|
+
".wav": "audio/wav",
|
|
1017
|
+
".m4a": "audio/mp4",
|
|
1018
|
+
".mp3": "audio/mpeg",
|
|
1019
|
+
".mp4": "video/mp4",
|
|
1020
|
+
".png": "image/png",
|
|
1021
|
+
".jpg": "image/jpeg",
|
|
1022
|
+
".jpeg": "image/jpeg",
|
|
1023
|
+
".webp": "image/webp"
|
|
1024
|
+
};
|
|
1025
|
+
return map[ext] ?? "application/octet-stream";
|
|
1026
|
+
}
|
|
1027
|
+
async function pullFiles(client, jobDir, opts = {}) {
|
|
1028
|
+
const listing = await client.filesList();
|
|
1029
|
+
const byPath = new Map(listing.map((f) => [f.path, f]));
|
|
1030
|
+
const prefixMatched = opts.prefixes?.length ? listing.map((f) => f.path).filter((path) => opts.prefixes.some((pre) => path.startsWith(pre))) : [];
|
|
1031
|
+
const explicit = opts.only && opts.only.length > 0 ? opts.only : listing.map((f) => f.path);
|
|
1032
|
+
const wanted = [.../* @__PURE__ */ new Set([...explicit, ...prefixMatched])];
|
|
1033
|
+
const missing = explicit.filter((p) => !byPath.has(p));
|
|
1034
|
+
const pulled = [];
|
|
1035
|
+
for (const path of wanted) {
|
|
1036
|
+
const entry = byPath.get(path);
|
|
1037
|
+
if (!entry) continue;
|
|
1038
|
+
const dest = vfsLocalPath(jobDir, path);
|
|
1039
|
+
if (existsSync5(dest) && sameSha(sha256Hex(readFileSync3(dest)), entry.sha256)) {
|
|
1040
|
+
const f2 = { path, localPath: dest, bytes: statSync3(dest).size, skipped: true };
|
|
1041
|
+
pulled.push(f2);
|
|
1042
|
+
opts.onFile?.(f2);
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
const got = await client.filesGet(path);
|
|
1046
|
+
mkdirSync5(dirname4(dest), { recursive: true });
|
|
1047
|
+
let bytes;
|
|
1048
|
+
if (got.content !== void 0) {
|
|
1049
|
+
const buf = Buffer.from(got.content, "utf8");
|
|
1050
|
+
writeFileSync4(dest, buf);
|
|
1051
|
+
bytes = buf.length;
|
|
1052
|
+
if (got.sha256 && !sameSha(sha256Hex(buf), got.sha256)) {
|
|
1053
|
+
throw new Error(`pulled ${path} but sha256 mismatch vs server (${got.sha256}) \u2014 refusing to trust the copy`);
|
|
1054
|
+
}
|
|
1055
|
+
} else {
|
|
1056
|
+
const dl = await client.download(got.downloadUrl, dest);
|
|
1057
|
+
bytes = dl.bytes;
|
|
1058
|
+
if (got.sha256 && !sameSha(sha256Hex(readFileSync3(dest)), got.sha256)) {
|
|
1059
|
+
throw new Error(`pulled ${path} but sha256 mismatch vs server (${got.sha256}) \u2014 refusing to trust the copy`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const f = { path, localPath: dest, bytes, skipped: false };
|
|
1063
|
+
pulled.push(f);
|
|
1064
|
+
opts.onFile?.(f);
|
|
1065
|
+
}
|
|
1066
|
+
return { pulled, missing };
|
|
1067
|
+
}
|
|
1068
|
+
async function pushFiles(client, jobDir, paths, opts = {}) {
|
|
1069
|
+
if (paths.length === 0) throw new Error("files push: pass at least one jobDir-relative path");
|
|
1070
|
+
const listing = await client.filesList();
|
|
1071
|
+
const byPath = new Map(listing.map((f) => [f.path, f]));
|
|
1072
|
+
const out = [];
|
|
1073
|
+
for (const rel of paths) {
|
|
1074
|
+
const vfsPath = toVfsPath(jobDir, rel);
|
|
1075
|
+
const local = vfsLocalPath(jobDir, vfsPath);
|
|
1076
|
+
if (!existsSync5(local)) throw new Error(`files push: local file not found: ${local}`);
|
|
1077
|
+
const bytes = readFileSync3(local);
|
|
1078
|
+
const sha = sha256Hex(bytes);
|
|
1079
|
+
const existing = byPath.get(vfsPath);
|
|
1080
|
+
if (existing && sameSha(sha, existing.sha256)) {
|
|
1081
|
+
const f = { path: vfsPath, bytes: bytes.length, skipped: true, mode: "skip" };
|
|
1082
|
+
out.push(f);
|
|
1083
|
+
opts.onFile?.(f);
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
const contentType = vfsContentType(vfsPath);
|
|
1087
|
+
const isText = TEXT_TYPES.test(contentType);
|
|
1088
|
+
if (isText && bytes.length <= INLINE_MAX_BYTES) {
|
|
1089
|
+
const res = await client.filesPutInline(vfsPath, bytes.toString("utf8"), contentType);
|
|
1090
|
+
if (res.sha256 && !sameSha(sha, res.sha256)) {
|
|
1091
|
+
throw new Error(`pushed ${vfsPath} but the server stored different bytes (sha256 mismatch)`);
|
|
1092
|
+
}
|
|
1093
|
+
const f = { path: vfsPath, bytes: bytes.length, skipped: false, mode: "inline" };
|
|
1094
|
+
out.push(f);
|
|
1095
|
+
opts.onFile?.(f);
|
|
1096
|
+
} else {
|
|
1097
|
+
const presign = await client.filesPresign({ path: vfsPath, contentType, bytes: bytes.length, sha256: sha });
|
|
1098
|
+
await client.upload(presign.uploadUrl, bytes, contentType);
|
|
1099
|
+
await client.filesCommit(vfsPath);
|
|
1100
|
+
const f = { path: vfsPath, bytes: bytes.length, skipped: false, mode: "presign" };
|
|
1101
|
+
out.push(f);
|
|
1102
|
+
opts.onFile?.(f);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return out;
|
|
1106
|
+
}
|
|
1107
|
+
async function uploadVfsBytes(client, vfsPath, bytes, contentType) {
|
|
1108
|
+
const presign = await client.filesPresign({
|
|
1109
|
+
path: vfsPath,
|
|
1110
|
+
contentType,
|
|
1111
|
+
bytes: bytes.length,
|
|
1112
|
+
sha256: sha256Hex(bytes)
|
|
1113
|
+
});
|
|
1114
|
+
await client.upload(presign.uploadUrl, bytes, contentType);
|
|
1115
|
+
await client.filesCommit(vfsPath);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/cloud/runs.ts
|
|
1119
|
+
var MAX_RUN_POLL_MS = 30 * 60 * 1e3;
|
|
1120
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1121
|
+
function formatRunEvent(ev) {
|
|
1122
|
+
const p = ev.payload;
|
|
1123
|
+
const message = p !== null && typeof p === "object" && typeof p.message === "string" ? p.message : "";
|
|
1124
|
+
return message ? `[${ev.type}] ${message}` : `[${ev.type}]`;
|
|
1125
|
+
}
|
|
1126
|
+
async function pollRun(client, runId, opts = {}) {
|
|
1127
|
+
const intervalMs = opts.intervalMs ?? 5e3;
|
|
1128
|
+
const maxMs = opts.maxMs ?? MAX_RUN_POLL_MS;
|
|
1129
|
+
const nap = opts.sleepImpl ?? sleep2;
|
|
1130
|
+
const now = opts.now ?? Date.now;
|
|
1131
|
+
const start = now();
|
|
1132
|
+
let seen = 0;
|
|
1133
|
+
for (; ; ) {
|
|
1134
|
+
const state = await client.getRun(runId);
|
|
1135
|
+
for (; seen < state.events.length; seen++) opts.onEvent?.(state.events[seen]);
|
|
1136
|
+
if (isTerminalRunStatus(state.status)) return state;
|
|
1137
|
+
const elapsed = now() - start;
|
|
1138
|
+
if (elapsed + intervalMs >= maxMs) {
|
|
1139
|
+
throw new Error(`run ${runId} did not finish within ${Math.round(maxMs / 6e4)} min (last status: ${state.status})`);
|
|
1140
|
+
}
|
|
1141
|
+
await nap(intervalMs);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
function assertRunCompleted(state, what) {
|
|
1145
|
+
if (state.status === "completed") return;
|
|
1146
|
+
const detail = state.errorMessage ?? state.errorCode ?? state.status;
|
|
1147
|
+
throw new Error(`${what} run ${state.id || ""} ${state.status}: ${detail}`);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// src/cloud/transcribe2.ts
|
|
1151
|
+
import { mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync2 } from "fs";
|
|
1152
|
+
import { tmpdir } from "os";
|
|
1153
|
+
import { join as join7 } from "path";
|
|
1154
|
+
function transcribeArtifactSet(stem) {
|
|
1155
|
+
return [`transcripts/${stem}.json`, "takes_packed.md", "flags.txt", "word_dump.txt", "coverage.json"];
|
|
1156
|
+
}
|
|
1157
|
+
async function extractGuardedWav(media) {
|
|
1158
|
+
const tmp = mkdtempSync(join7(tmpdir(), "ve-cloud-wav-"));
|
|
1159
|
+
try {
|
|
1160
|
+
const wav = join7(tmp, "audio.wav");
|
|
1161
|
+
await run(
|
|
1162
|
+
["ffmpeg", "-y", "-i", media, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav],
|
|
1163
|
+
{ capture: true, echo: false }
|
|
1164
|
+
);
|
|
1165
|
+
const audioS = await mediaDuration(wav);
|
|
1166
|
+
const videoS = await mediaDuration(media);
|
|
1167
|
+
if (Number.isFinite(audioS) && Number.isFinite(videoS) && Math.abs(audioS - videoS) > 0.15) {
|
|
1168
|
+
throw new Error(
|
|
1169
|
+
`source A/V clocks diverge: decoded audio is ${audioS.toFixed(3)}s but video is ${videoS.toFixed(3)}s (\u0394 ${(audioS - videoS).toFixed(3)}s). Word timestamps would drift against the picture by up to that much. Re-export the source with a clean muxer (e.g. \`ffmpeg -i in -c:v copy -af aresample=async=1 out.mp4\`) and retry.`
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
return { bytes: readFileSync4(wav), durationS: audioS };
|
|
1173
|
+
} finally {
|
|
1174
|
+
rmSync2(tmp, { recursive: true, force: true });
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
async function transcribeCloud(client, jobDir, opts) {
|
|
1178
|
+
const verbose = opts.verbose !== false;
|
|
1179
|
+
const log = (line) => {
|
|
1180
|
+
if (verbose) process.stderr.write(line + "\n");
|
|
1181
|
+
};
|
|
1182
|
+
log(` extracting audio from ${opts.media}`);
|
|
1183
|
+
const { bytes, durationS } = await extractGuardedWav(opts.media);
|
|
1184
|
+
const wavPath = `audio/${opts.stem}.wav`;
|
|
1185
|
+
log(` uploading ${wavPath} (${(bytes.length / (1024 * 1024)).toFixed(1)} MB)`);
|
|
1186
|
+
await uploadVfsBytes(client, wavPath, bytes, "audio/wav");
|
|
1187
|
+
const { runId } = await client.transcribeStart({
|
|
1188
|
+
wav_path: wavPath,
|
|
1189
|
+
stem: opts.stem,
|
|
1190
|
+
duration_s: durationS,
|
|
1191
|
+
...opts.language !== void 0 ? { language: opts.language } : {},
|
|
1192
|
+
...opts.numSpeakers !== void 0 ? { num_speakers: opts.numSpeakers } : {}
|
|
1193
|
+
});
|
|
1194
|
+
log(` transcribe run: ${runId}`);
|
|
1195
|
+
const state = await pollRun(client, runId, {
|
|
1196
|
+
intervalMs: opts.pollIntervalMs,
|
|
1197
|
+
onEvent: (ev) => log(` ${formatRunEvent(ev)}`)
|
|
1198
|
+
});
|
|
1199
|
+
assertRunCompleted(state, "transcribe");
|
|
1200
|
+
const pulled = await pullFiles(client, jobDir, { only: transcribeArtifactSet(opts.stem), prefixes: ["prompts/"] });
|
|
1201
|
+
return { runId, wavPath, durationS, pulled };
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// src/silence-detect.ts
|
|
1205
|
+
var DEFAULT_DESILENCE = {
|
|
1206
|
+
noiseDb: -36,
|
|
1207
|
+
minSilence: 0.14,
|
|
1208
|
+
targetGap: 0.09,
|
|
1209
|
+
leadKeep: 0.04,
|
|
1210
|
+
trailKeep: 0.05,
|
|
1211
|
+
minSegment: 0.1
|
|
1212
|
+
};
|
|
1213
|
+
function f3(x) {
|
|
1214
|
+
return pyFixed(x, 3);
|
|
1215
|
+
}
|
|
1216
|
+
function silenceDetectArgs(source, noiseDb, minSilenceS) {
|
|
1217
|
+
return [
|
|
1218
|
+
"ffmpeg",
|
|
1219
|
+
"-hide_banner",
|
|
1220
|
+
"-nostats",
|
|
1221
|
+
"-i",
|
|
1222
|
+
source,
|
|
1223
|
+
"-af",
|
|
1224
|
+
`silencedetect=noise=${noiseDb}dB:d=${f3(minSilenceS)}`,
|
|
1225
|
+
"-vn",
|
|
1226
|
+
"-f",
|
|
1227
|
+
"null",
|
|
1228
|
+
"-"
|
|
1229
|
+
];
|
|
1230
|
+
}
|
|
1231
|
+
function parseSilenceDetect(stderr, sourceDurationS = 0) {
|
|
1232
|
+
const intervals = [];
|
|
1233
|
+
let pendingStart = null;
|
|
1234
|
+
for (const line of stderr.split(/\r?\n/)) {
|
|
1235
|
+
const mStart = line.match(/silence_start:\s*(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
1236
|
+
if (mStart) {
|
|
1237
|
+
pendingStart = Number(mStart[1]);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
const mEnd = line.match(/silence_end:\s*(-?[0-9]+(?:\.[0-9]+)?)/);
|
|
1241
|
+
if (mEnd && pendingStart !== null) {
|
|
1242
|
+
const end = Number(mEnd[1]);
|
|
1243
|
+
if (end > pendingStart) intervals.push({ start: pendingStart, end });
|
|
1244
|
+
pendingStart = null;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
if (pendingStart !== null && sourceDurationS > pendingStart) {
|
|
1248
|
+
intervals.push({ start: pendingStart, end: sourceDurationS });
|
|
1249
|
+
}
|
|
1250
|
+
return intervals;
|
|
1251
|
+
}
|
|
1252
|
+
async function detectSilences(source, noiseDb, minSilenceS, sourceDurationS) {
|
|
1253
|
+
const res = await run(silenceDetectArgs(source, noiseDb, minSilenceS), {
|
|
1254
|
+
capture: true,
|
|
1255
|
+
check: false,
|
|
1256
|
+
echo: false
|
|
1257
|
+
});
|
|
1258
|
+
return parseSilenceDetect(res.stderr, sourceDurationS);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// src/timeline-v2/schema.ts
|
|
1262
|
+
import { z } from "zod";
|
|
1263
|
+
var provenanceSchema = z.object({
|
|
1264
|
+
round: z.number().int().nullable().default(null),
|
|
1265
|
+
changeId: z.string().nullable().default(null),
|
|
1266
|
+
actor: z.enum(["agent", "user"]).default("agent")
|
|
1267
|
+
}).strict().default({ round: null, changeId: null, actor: "agent" });
|
|
1268
|
+
var wordsAnchorSchema = z.object({
|
|
1269
|
+
type: z.literal("words"),
|
|
1270
|
+
source: z.string().min(1),
|
|
1271
|
+
phrase: z.string().min(1),
|
|
1272
|
+
/** 1-based occurrence index when the phrase appears more than once. */
|
|
1273
|
+
occurrence: z.number().int().min(1).optional(),
|
|
1274
|
+
/** Cached resolution in SOURCE seconds. */
|
|
1275
|
+
resolved: z.object({ start: z.number().min(0), end: z.number().min(0) }).strict().optional(),
|
|
1276
|
+
/** Collapse the span to one instant (phrase start or end). */
|
|
1277
|
+
edge: z.enum(["start", "end"]).optional()
|
|
1278
|
+
}).strict();
|
|
1279
|
+
var sourceAnchorSchema = z.object({
|
|
1280
|
+
type: z.literal("source"),
|
|
1281
|
+
source: z.string().min(1),
|
|
1282
|
+
start: z.number().min(0),
|
|
1283
|
+
end: z.number().min(0)
|
|
1284
|
+
}).strict();
|
|
1285
|
+
var outputAnchorSchema = z.object({
|
|
1286
|
+
type: z.literal("output"),
|
|
1287
|
+
at: z.union([z.enum(["start", "end", "full"]), z.object({ after: z.string().min(1) }).strict()]),
|
|
1288
|
+
durationS: z.number().positive().optional()
|
|
1289
|
+
}).strict();
|
|
1290
|
+
var anchorSchema = z.discriminatedUnion("type", [
|
|
1291
|
+
wordsAnchorSchema,
|
|
1292
|
+
sourceAnchorSchema,
|
|
1293
|
+
outputAnchorSchema
|
|
1294
|
+
]);
|
|
1295
|
+
var timelineSourceSchema = z.object({
|
|
1296
|
+
/** Path (absolute or job-dir-relative) to the media file. */
|
|
1297
|
+
path: z.string().min(1),
|
|
1298
|
+
durationS: z.number().positive().optional(),
|
|
1299
|
+
/** "sha256:<hex>" of the transcript JSON this doc's cached anchor
|
|
1300
|
+
* resolutions were computed against. */
|
|
1301
|
+
transcriptHash: z.string().optional()
|
|
1302
|
+
}).strict();
|
|
1303
|
+
var desilenceSettingsSchema = z.object({
|
|
1304
|
+
noiseDb: z.number(),
|
|
1305
|
+
minSilence: z.number().positive(),
|
|
1306
|
+
targetGap: z.number().min(0),
|
|
1307
|
+
leadKeep: z.number().min(0),
|
|
1308
|
+
trailKeep: z.number().min(0),
|
|
1309
|
+
minSegment: z.number().min(0)
|
|
1310
|
+
}).partial().strict();
|
|
1311
|
+
var gradeSettingSchema = z.object({ preset: z.string(), intensity: z.number().min(0).max(1).optional() }).strict();
|
|
1312
|
+
var settingsSchema = z.object({
|
|
1313
|
+
canvas: z.object({ w: z.number().int().positive(), h: z.number().int().positive() }).strict().default({ w: 1080, h: 1920 }),
|
|
1314
|
+
/** "none" | preset name | raw ffmpeg filter | "auto" (same as EDL grade),
|
|
1315
|
+
* or a declarative {preset, intensity?} grade for the composition path. */
|
|
1316
|
+
grade: z.union([z.string(), gradeSettingSchema]).default("none"),
|
|
1317
|
+
loudnorm: z.boolean().default(true),
|
|
1318
|
+
/** Optional DesilenceOptions subset; merged over DEFAULT_DESILENCE. */
|
|
1319
|
+
desilence: desilenceSettingsSchema.optional()
|
|
1320
|
+
}).strict().default({ canvas: { w: 1080, h: 1920 }, grade: "none", loudnorm: true });
|
|
1321
|
+
var cutRangeSchema = z.object({
|
|
1322
|
+
id: z.string().min(1),
|
|
1323
|
+
source: z.string().min(1),
|
|
1324
|
+
start: z.number().min(0),
|
|
1325
|
+
end: z.number().min(0),
|
|
1326
|
+
beat: z.string().optional(),
|
|
1327
|
+
quote: z.string().optional(),
|
|
1328
|
+
reason: z.string().optional(),
|
|
1329
|
+
/** Optional per-range grade override (kept for v1 EDL round-trips). */
|
|
1330
|
+
grade: z.string().optional(),
|
|
1331
|
+
provenance: provenanceSchema
|
|
1332
|
+
}).strict();
|
|
1333
|
+
var cutSchema = z.object({ ranges: z.array(cutRangeSchema).default([]) }).strict().default({ ranges: [] });
|
|
1334
|
+
var CSS_COLOR_RE = /^(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|[a-zA-Z]+)$/;
|
|
1335
|
+
var captionOverrideSchema = z.object({
|
|
1336
|
+
id: z.string().min(1),
|
|
1337
|
+
anchor: wordsAnchorSchema,
|
|
1338
|
+
op: z.enum(["replace-text", "hide", "break-line", "style"]),
|
|
1339
|
+
text: z.string().optional(),
|
|
1340
|
+
/** op:"style" — per-word color (hex or simple CSS color name). */
|
|
1341
|
+
color: z.string().regex(CSS_COLOR_RE, "must be a hex color or a simple CSS color name").optional(),
|
|
1342
|
+
/** op:"style" — per-word animated emphasis at the word's onset. */
|
|
1343
|
+
emphasis: z.enum(["pop", "highlight"]).optional(),
|
|
1344
|
+
provenance: provenanceSchema
|
|
1345
|
+
}).strict();
|
|
1346
|
+
var captionPresetSchema = z.enum(["grouped", "opus-karaoke"]);
|
|
1347
|
+
var captionStyleSchema = z.object({
|
|
1348
|
+
/** Max words per grouped cue (preset "grouped" only). */
|
|
1349
|
+
maxWords: z.number().int().min(1).max(20).optional(),
|
|
1350
|
+
/** Inter-word gap (seconds) that ends a phrase — a hard cue boundary
|
|
1351
|
+
* (preset "grouped" only). */
|
|
1352
|
+
gapBreakS: z.number().min(0).max(5).optional(),
|
|
1353
|
+
/** Seconds each cue's display start is shifted EARLIER (clamped to the
|
|
1354
|
+
* previous cue's end / the segment's outStart). */
|
|
1355
|
+
leadS: z.number().min(0).max(1).optional(),
|
|
1356
|
+
/** Max seconds a cue is held past its last word to bridge the gap to the
|
|
1357
|
+
* next cue (never past the segment's outEnd). */
|
|
1358
|
+
holdS: z.number().min(0).max(5).optional(),
|
|
1359
|
+
/** Per-word highlight sweep within grouped cues (karaoke-style). */
|
|
1360
|
+
karaoke: z.boolean().optional()
|
|
1361
|
+
}).passthrough();
|
|
1362
|
+
var CAPTION_STYLE_DEFAULTS = { maxWords: 4, gapBreakS: 0.6, leadS: 0.08, holdS: 0.9 };
|
|
1363
|
+
var captionsSchema = z.object({
|
|
1364
|
+
enabled: z.boolean().default(false),
|
|
1365
|
+
preset: captionPresetSchema.default("grouped"),
|
|
1366
|
+
style: captionStyleSchema.default({}),
|
|
1367
|
+
overrides: z.array(captionOverrideSchema).default([])
|
|
1368
|
+
}).strict().default({ enabled: false, preset: "grouped", style: {}, overrides: [] });
|
|
1369
|
+
var onCutSchema = z.enum(["clip", "drop", "error"]);
|
|
1370
|
+
var zoomRectSchema = z.object({
|
|
1371
|
+
x: z.number().min(0).max(1),
|
|
1372
|
+
y: z.number().min(0).max(1),
|
|
1373
|
+
w: z.number().min(0).max(1),
|
|
1374
|
+
h: z.number().min(0).max(1)
|
|
1375
|
+
}).strict();
|
|
1376
|
+
var zoomSchema = z.object({
|
|
1377
|
+
id: z.string().min(1),
|
|
1378
|
+
anchor: anchorSchema,
|
|
1379
|
+
onCut: onCutSchema.default("clip"),
|
|
1380
|
+
rect: zoomRectSchema,
|
|
1381
|
+
scale: z.number().gt(1).lte(3),
|
|
1382
|
+
/** Ramp target: when present, the zoom animates from the base rect/scale
|
|
1383
|
+
* to `to` over the zoom window (`ease` shapes the ramp). */
|
|
1384
|
+
to: z.object({
|
|
1385
|
+
rect: zoomRectSchema.optional(),
|
|
1386
|
+
scale: z.number().gt(1).lte(3).optional()
|
|
1387
|
+
}).strict().optional(),
|
|
1388
|
+
ease: z.object({ in: z.number().min(0), out: z.number().min(0) }).strict().default({ in: 0.25, out: 0.25 }),
|
|
1389
|
+
provenance: provenanceSchema
|
|
1390
|
+
}).strict();
|
|
1391
|
+
var assetRefSchema = z.object({ ref: z.string().min(1) }).strict();
|
|
1392
|
+
var overlayFxSchema = z.object({
|
|
1393
|
+
preset: z.enum(["fade", "pop", "slide-up", "slide-down", "scale-in"]),
|
|
1394
|
+
durationS: z.number().positive().max(2)
|
|
1395
|
+
}).strict();
|
|
1396
|
+
var overlaySchema = z.object({
|
|
1397
|
+
id: z.string().min(1),
|
|
1398
|
+
kind: z.enum(["text", "title-card", "image", "sticker"]),
|
|
1399
|
+
anchor: anchorSchema,
|
|
1400
|
+
onCut: onCutSchema.default("drop"),
|
|
1401
|
+
layout: z.object({
|
|
1402
|
+
x: z.number().min(0).max(1).default(0.5),
|
|
1403
|
+
y: z.number().min(0).max(1).default(0.82),
|
|
1404
|
+
align: z.enum(["left", "center", "right"]).default("center"),
|
|
1405
|
+
safeArea: z.boolean().default(true)
|
|
1406
|
+
}).strict().default({ x: 0.5, y: 0.82, align: "center", safeArea: true }),
|
|
1407
|
+
text: z.string().optional(),
|
|
1408
|
+
style: z.object({ preset: z.string().optional() }).passthrough().default({}),
|
|
1409
|
+
durationS: z.number().positive().optional(),
|
|
1410
|
+
asset: assetRefSchema.optional(),
|
|
1411
|
+
/** Entrance/exit presets (image/sticker overlays). */
|
|
1412
|
+
enter: overlayFxSchema.optional(),
|
|
1413
|
+
exit: overlayFxSchema.optional(),
|
|
1414
|
+
/** Size as canvas fractions (w of canvas width, h of canvas height). */
|
|
1415
|
+
size: z.object({
|
|
1416
|
+
w: z.number().min(0).max(1).optional(),
|
|
1417
|
+
h: z.number().min(0).max(1).optional()
|
|
1418
|
+
}).strict().optional(),
|
|
1419
|
+
/** Stacking order (CSS z-index); temporal lanes stay non-overlapping. */
|
|
1420
|
+
z: z.number().int().min(0).max(50).optional(),
|
|
1421
|
+
provenance: provenanceSchema
|
|
1422
|
+
}).strict();
|
|
1423
|
+
var audioSchema = z.object({
|
|
1424
|
+
id: z.string().min(1),
|
|
1425
|
+
kind: z.enum(["music", "sfx"]),
|
|
1426
|
+
asset: assetRefSchema,
|
|
1427
|
+
anchor: anchorSchema,
|
|
1428
|
+
gainDb: z.number().default(-18),
|
|
1429
|
+
duckToSpeech: z.boolean().default(true),
|
|
1430
|
+
fadeOutS: z.number().min(0).optional(),
|
|
1431
|
+
provenance: provenanceSchema
|
|
1432
|
+
}).strict();
|
|
1433
|
+
var timelineV2Schema = z.object({
|
|
1434
|
+
version: z.literal(2),
|
|
1435
|
+
id: z.string().regex(/^tl_/, 'timeline id must start with "tl_"'),
|
|
1436
|
+
sources: z.record(timelineSourceSchema).refine((s) => Object.keys(s).length > 0, {
|
|
1437
|
+
message: "at least one source is required"
|
|
1438
|
+
}),
|
|
1439
|
+
settings: settingsSchema,
|
|
1440
|
+
cut: cutSchema,
|
|
1441
|
+
captions: captionsSchema,
|
|
1442
|
+
zooms: z.array(zoomSchema).default([]),
|
|
1443
|
+
overlays: z.array(overlaySchema).default([]),
|
|
1444
|
+
audio: z.array(audioSchema).default([])
|
|
1445
|
+
}).strict();
|
|
1446
|
+
function emptyDiagnostics() {
|
|
1447
|
+
return { errors: [], warnings: [], dropped: [] };
|
|
1448
|
+
}
|
|
1449
|
+
function parseTimeline(raw) {
|
|
1450
|
+
if (raw !== null && typeof raw === "object" && typeof raw.captions === "object" && raw.captions !== null && "cues" in raw.captions) {
|
|
1451
|
+
return {
|
|
1452
|
+
doc: null,
|
|
1453
|
+
errors: [
|
|
1454
|
+
{
|
|
1455
|
+
path: "captions.cues",
|
|
1456
|
+
code: "captions-authored-cues",
|
|
1457
|
+
message: "caption cues are derived by compile and must never be stored in timeline.json",
|
|
1458
|
+
suggestion: "remove captions.cues; author only enabled/preset/style/overrides"
|
|
1459
|
+
}
|
|
1460
|
+
]
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
const parsed = timelineV2Schema.safeParse(raw);
|
|
1464
|
+
if (!parsed.success) {
|
|
1465
|
+
return {
|
|
1466
|
+
doc: null,
|
|
1467
|
+
errors: parsed.error.issues.map((iss) => ({
|
|
1468
|
+
path: iss.path.map((p) => typeof p === "number" ? `[${p}]` : p).join(".").replace(/\.\[/g, "["),
|
|
1469
|
+
code: `schema:${iss.code}`,
|
|
1470
|
+
message: iss.message
|
|
1471
|
+
}))
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
return { doc: parsed.data, errors: [] };
|
|
1475
|
+
}
|
|
1476
|
+
var EPS = 1e-6;
|
|
1477
|
+
function itemIds(doc) {
|
|
1478
|
+
return [
|
|
1479
|
+
...doc.cut.ranges.map((r) => r.id),
|
|
1480
|
+
...doc.captions.overrides.map((o) => o.id),
|
|
1481
|
+
...doc.zooms.map((zm) => zm.id),
|
|
1482
|
+
...doc.overlays.map((ov) => ov.id),
|
|
1483
|
+
...doc.audio.map((a) => a.id)
|
|
1484
|
+
];
|
|
1485
|
+
}
|
|
1486
|
+
function checkAnchorSource(anchor, doc, path, errors) {
|
|
1487
|
+
if (anchor.type === "output") return;
|
|
1488
|
+
if (!(anchor.source in doc.sources)) {
|
|
1489
|
+
errors.push({
|
|
1490
|
+
path: `${path}.source`,
|
|
1491
|
+
code: "unknown-source",
|
|
1492
|
+
message: `anchor references unknown source "${anchor.source}"`,
|
|
1493
|
+
suggestion: `known sources: ${Object.keys(doc.sources).join(", ")}`
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
if (anchor.type === "source" && anchor.end <= anchor.start) {
|
|
1497
|
+
errors.push({
|
|
1498
|
+
path,
|
|
1499
|
+
code: "bad-anchor-span",
|
|
1500
|
+
message: `source anchor end (${anchor.end}) must be greater than start (${anchor.start})`
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
function validateTimeline(doc) {
|
|
1505
|
+
const errors = [];
|
|
1506
|
+
const warnings = [];
|
|
1507
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1508
|
+
for (const id of itemIds(doc)) seen.set(id, (seen.get(id) ?? 0) + 1);
|
|
1509
|
+
for (const [id, count] of seen) {
|
|
1510
|
+
if (count > 1) {
|
|
1511
|
+
errors.push({
|
|
1512
|
+
path: "(items)",
|
|
1513
|
+
code: "duplicate-id",
|
|
1514
|
+
message: `item id "${id}" is used ${count} times; ids must be unique across all items`
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const lastEndBySource = /* @__PURE__ */ new Map();
|
|
1519
|
+
doc.cut.ranges.forEach((r, i) => {
|
|
1520
|
+
const path = `cut.ranges[${i}]`;
|
|
1521
|
+
if (!(r.source in doc.sources)) {
|
|
1522
|
+
errors.push({
|
|
1523
|
+
path: `${path}.source`,
|
|
1524
|
+
code: "unknown-source",
|
|
1525
|
+
message: `cut range "${r.id}" references unknown source "${r.source}"`,
|
|
1526
|
+
suggestion: `known sources: ${Object.keys(doc.sources).join(", ")}`
|
|
1527
|
+
});
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (r.end <= r.start) {
|
|
1531
|
+
errors.push({
|
|
1532
|
+
path,
|
|
1533
|
+
code: "bad-range",
|
|
1534
|
+
message: `cut range "${r.id}" has end (${r.end}) <= start (${r.start})`
|
|
1535
|
+
});
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
const dur = doc.sources[r.source].durationS;
|
|
1539
|
+
if (dur !== void 0 && r.end > dur + EPS) {
|
|
1540
|
+
errors.push({
|
|
1541
|
+
path: `${path}.end`,
|
|
1542
|
+
code: "range-out-of-bounds",
|
|
1543
|
+
message: `cut range "${r.id}" ends at ${r.end}s but source "${r.source}" is ${dur}s long`
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
const prev = lastEndBySource.get(r.source);
|
|
1547
|
+
if (prev !== void 0) {
|
|
1548
|
+
if (r.start < prev.end - EPS) {
|
|
1549
|
+
const code = r.start <= doc.cut.ranges[prev.index].start + EPS ? "range-order" : "range-overlap";
|
|
1550
|
+
errors.push({
|
|
1551
|
+
path,
|
|
1552
|
+
code,
|
|
1553
|
+
message: code === "range-order" ? `cut range "${r.id}" is out of order: ranges of source "${r.source}" must be sorted by start` : `cut range "${r.id}" overlaps the previous range of source "${r.source}" (starts at ${r.start} before previous end ${prev.end})`
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
lastEndBySource.set(r.source, { end: r.end, index: i });
|
|
1558
|
+
});
|
|
1559
|
+
doc.captions.overrides.forEach((o, i) => {
|
|
1560
|
+
checkAnchorSource(o.anchor, doc, `captions.overrides[${i}].anchor`, errors);
|
|
1561
|
+
if (o.op === "replace-text" && (o.text === void 0 || o.text.length === 0)) {
|
|
1562
|
+
errors.push({
|
|
1563
|
+
path: `captions.overrides[${i}].text`,
|
|
1564
|
+
code: "missing-text",
|
|
1565
|
+
message: `caption override "${o.id}" has op "replace-text" but no text`
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
if (o.op === "style" && o.color === void 0 && o.emphasis === void 0) {
|
|
1569
|
+
errors.push({
|
|
1570
|
+
path: `captions.overrides[${i}]`,
|
|
1571
|
+
code: "missing-style",
|
|
1572
|
+
message: `caption override "${o.id}" has op "style" but neither color nor emphasis`,
|
|
1573
|
+
suggestion: 'set color (e.g. "#ff5c5c") and/or emphasis ("pop" | "highlight")'
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
doc.zooms.forEach((zm, i) => {
|
|
1578
|
+
const path = `zooms[${i}]`;
|
|
1579
|
+
checkAnchorSource(zm.anchor, doc, `${path}.anchor`, errors);
|
|
1580
|
+
if (zm.rect.x + zm.rect.w > 1 + EPS || zm.rect.y + zm.rect.h > 1 + EPS) {
|
|
1581
|
+
errors.push({
|
|
1582
|
+
path: `${path}.rect`,
|
|
1583
|
+
code: "bad-rect",
|
|
1584
|
+
message: `zoom "${zm.id}" rect exceeds the unit canvas (x+w=${zm.rect.x + zm.rect.w}, y+h=${zm.rect.y + zm.rect.h})`,
|
|
1585
|
+
suggestion: "keep x+w <= 1 and y+h <= 1"
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
if (zm.to !== void 0) {
|
|
1589
|
+
if (zm.to.rect === void 0 && zm.to.scale === void 0) {
|
|
1590
|
+
errors.push({
|
|
1591
|
+
path: `${path}.to`,
|
|
1592
|
+
code: "empty-ramp",
|
|
1593
|
+
message: `zoom "${zm.id}" has a ramp target "to" with neither rect nor scale`,
|
|
1594
|
+
suggestion: "set to.rect and/or to.scale, or remove to"
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
const tr = zm.to.rect;
|
|
1598
|
+
if (tr && (tr.x + tr.w > 1 + EPS || tr.y + tr.h > 1 + EPS)) {
|
|
1599
|
+
errors.push({
|
|
1600
|
+
path: `${path}.to.rect`,
|
|
1601
|
+
code: "bad-rect",
|
|
1602
|
+
message: `zoom "${zm.id}" to.rect exceeds the unit canvas (x+w=${tr.x + tr.w}, y+h=${tr.y + tr.h})`,
|
|
1603
|
+
suggestion: "keep x+w <= 1 and y+h <= 1"
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
doc.overlays.forEach((ov, i) => {
|
|
1609
|
+
const path = `overlays[${i}]`;
|
|
1610
|
+
checkAnchorSource(ov.anchor, doc, `${path}.anchor`, errors);
|
|
1611
|
+
if ((ov.kind === "image" || ov.kind === "sticker") && !ov.asset) {
|
|
1612
|
+
errors.push({
|
|
1613
|
+
path: `${path}.asset`,
|
|
1614
|
+
code: "missing-asset",
|
|
1615
|
+
message: `overlay "${ov.id}" has kind "${ov.kind}" but no asset`
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
if ((ov.kind === "text" || ov.kind === "title-card") && (ov.text === void 0 || ov.text.length === 0)) {
|
|
1619
|
+
errors.push({
|
|
1620
|
+
path: `${path}.text`,
|
|
1621
|
+
code: "missing-text",
|
|
1622
|
+
message: `overlay "${ov.id}" has kind "${ov.kind}" but no text`
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
doc.audio.forEach((a, i) => {
|
|
1627
|
+
checkAnchorSource(a.anchor, doc, `audio[${i}].anchor`, errors);
|
|
1628
|
+
});
|
|
1629
|
+
const fullAudio = doc.audio.filter((a) => a.anchor.type === "output" && a.anchor.at === "full");
|
|
1630
|
+
if (fullAudio.length > 1) {
|
|
1631
|
+
errors.push({
|
|
1632
|
+
path: "audio",
|
|
1633
|
+
code: "multiple-full-audio",
|
|
1634
|
+
message: `at most one audio item may use at:"full" (found ${fullAudio.length}: ${fullAudio.map((a) => a.id).join(", ")})`
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
const ids = new Set(itemIds(doc));
|
|
1638
|
+
const checkAfter = (anchor, ownerId, path) => {
|
|
1639
|
+
if (anchor.type !== "output" || typeof anchor.at === "string") return;
|
|
1640
|
+
const target = anchor.at.after;
|
|
1641
|
+
if (!ids.has(target)) {
|
|
1642
|
+
errors.push({
|
|
1643
|
+
path,
|
|
1644
|
+
code: "unknown-after-target",
|
|
1645
|
+
message: `after:"${target}" does not reference any existing item id`
|
|
1646
|
+
});
|
|
1647
|
+
} else if (target === ownerId) {
|
|
1648
|
+
errors.push({
|
|
1649
|
+
path,
|
|
1650
|
+
code: "after-cycle",
|
|
1651
|
+
message: `item "${ownerId}" cannot be anchored after itself`
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
doc.zooms.forEach((zm, i) => checkAfter(zm.anchor, zm.id, `zooms[${i}].anchor.at.after`));
|
|
1656
|
+
doc.overlays.forEach((ov, i) => checkAfter(ov.anchor, ov.id, `overlays[${i}].anchor.at.after`));
|
|
1657
|
+
doc.audio.forEach((a, i) => checkAfter(a.anchor, a.id, `audio[${i}].anchor.at.after`));
|
|
1658
|
+
return { errors, warnings };
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// src/timeline-v2/capabilities.ts
|
|
1662
|
+
var CAPABILITIES = {
|
|
1663
|
+
cut: { status: "ready" },
|
|
1664
|
+
"captions.karaoke": { status: "ready" },
|
|
1665
|
+
// Per-word color/emphasis styling — rendered by composeHtml (render-proven).
|
|
1666
|
+
"captions.word-style": { status: "ready" },
|
|
1667
|
+
// Karaoke per-word sweep compiles + composes, but the sweep itself has not
|
|
1668
|
+
// been through a validation render yet.
|
|
1669
|
+
"captions.karaoke-highlight": { status: "staged" },
|
|
1670
|
+
zoom: { status: "ready" },
|
|
1671
|
+
// Animated zoom ramps (base → to over the window) — render-proven GSAP path.
|
|
1672
|
+
"zoom.ramp": { status: "ready" },
|
|
1673
|
+
"overlay.text": { status: "ready" },
|
|
1674
|
+
"overlay.title-card": { status: "ready" },
|
|
1675
|
+
"overlay.image": { status: "ready" },
|
|
1676
|
+
"overlay.sticker": { status: "ready" },
|
|
1677
|
+
"overlay.enter-exit": { status: "ready" },
|
|
1678
|
+
// Audio beds/sfx are schema-complete but the render integration is staged.
|
|
1679
|
+
"audio.music": { status: "staged" },
|
|
1680
|
+
"audio.sfx": { status: "staged" },
|
|
1681
|
+
// Declarative color grade (data-color-grading) — not yet render-proven.
|
|
1682
|
+
grade: { status: "staged" }
|
|
1683
|
+
};
|
|
1684
|
+
function usesFromDoc(doc) {
|
|
1685
|
+
const uses = [];
|
|
1686
|
+
const add = (k) => {
|
|
1687
|
+
if (!uses.includes(k)) uses.push(k);
|
|
1688
|
+
};
|
|
1689
|
+
if (doc.cut.ranges.length > 0) add("cut");
|
|
1690
|
+
if (doc.captions.enabled) {
|
|
1691
|
+
add("captions.karaoke");
|
|
1692
|
+
if (doc.captions.overrides.some((o) => o.op === "style")) add("captions.word-style");
|
|
1693
|
+
if (doc.captions.style.karaoke === true) add("captions.karaoke-highlight");
|
|
1694
|
+
}
|
|
1695
|
+
if (doc.zooms.length > 0) add("zoom");
|
|
1696
|
+
if (doc.zooms.some((zm) => zm.to !== void 0)) add("zoom.ramp");
|
|
1697
|
+
for (const ov of doc.overlays) {
|
|
1698
|
+
add(`overlay.${ov.kind}`);
|
|
1699
|
+
if (ov.enter !== void 0 || ov.exit !== void 0) add("overlay.enter-exit");
|
|
1700
|
+
}
|
|
1701
|
+
for (const a of doc.audio) add(`audio.${a.kind}`);
|
|
1702
|
+
if (typeof doc.settings.grade === "object") add("grade");
|
|
1703
|
+
return uses;
|
|
1704
|
+
}
|
|
1705
|
+
function checkCapabilities(uses, registry = CAPABILITIES) {
|
|
1706
|
+
const errors = [];
|
|
1707
|
+
const warnings = [];
|
|
1708
|
+
for (const key of uses) {
|
|
1709
|
+
const status = registry[key]?.status ?? "none";
|
|
1710
|
+
if (status === "none") {
|
|
1711
|
+
errors.push({
|
|
1712
|
+
path: "(capabilities)",
|
|
1713
|
+
code: "capability-none",
|
|
1714
|
+
message: `feature "${key}" is used by this timeline but is not available in this build`,
|
|
1715
|
+
suggestion: "remove the items using it, or upgrade the renderer",
|
|
1716
|
+
context: { feature: key, status }
|
|
1717
|
+
});
|
|
1718
|
+
} else if (status === "staged") {
|
|
1719
|
+
warnings.push({
|
|
1720
|
+
path: "(capabilities)",
|
|
1721
|
+
code: "capability-staged",
|
|
1722
|
+
message: `feature "${key}" is staged: it compiles into the plan but the render path may not honor it yet`,
|
|
1723
|
+
context: { feature: key, status }
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return { errors, warnings };
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/cloud/compile2.ts
|
|
1731
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
|
|
1732
|
+
import { isAbsolute, join as join8, resolve as resolve2 } from "path";
|
|
1733
|
+
function timelineJsonPath2(jobDir) {
|
|
1734
|
+
return join8(jobDir, "timeline.json");
|
|
1735
|
+
}
|
|
1736
|
+
function planPath2(jobDir) {
|
|
1737
|
+
return join8(jobDir, "resolved", "plan.json");
|
|
1738
|
+
}
|
|
1739
|
+
function diagnosticsPath2(jobDir) {
|
|
1740
|
+
return join8(jobDir, "resolved", "diagnostics.json");
|
|
1741
|
+
}
|
|
1742
|
+
function loadTimeline2(jobDir) {
|
|
1743
|
+
const path = timelineJsonPath2(jobDir);
|
|
1744
|
+
if (!existsSync6(path)) {
|
|
1745
|
+
return {
|
|
1746
|
+
doc: null,
|
|
1747
|
+
raw: null,
|
|
1748
|
+
errors: [{ path: "(file)", code: "missing-file", message: `timeline not found: ${path}` }]
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
let raw;
|
|
1752
|
+
try {
|
|
1753
|
+
raw = JSON.parse(readFileSync5(path, "utf8"));
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
return {
|
|
1756
|
+
doc: null,
|
|
1757
|
+
raw: null,
|
|
1758
|
+
errors: [
|
|
1759
|
+
{
|
|
1760
|
+
path: "(file)",
|
|
1761
|
+
code: "invalid-json",
|
|
1762
|
+
message: `timeline.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`
|
|
1763
|
+
}
|
|
1764
|
+
]
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
const parsed = parseTimeline(raw);
|
|
1768
|
+
return { doc: parsed.doc, raw, errors: parsed.errors };
|
|
1769
|
+
}
|
|
1770
|
+
function validateLocal(doc) {
|
|
1771
|
+
const sem = validateTimeline(doc);
|
|
1772
|
+
const uses = usesFromDoc(doc);
|
|
1773
|
+
const cap = checkCapabilities(uses);
|
|
1774
|
+
const errors = [...sem.errors, ...cap.errors];
|
|
1775
|
+
const warnings = [...sem.warnings, ...cap.warnings];
|
|
1776
|
+
return { ok: errors.length === 0, uses, errors, warnings };
|
|
1777
|
+
}
|
|
1778
|
+
async function detectSilencesForDoc(jobDir, doc) {
|
|
1779
|
+
const dopts = { ...DEFAULT_DESILENCE, ...doc.settings.desilence ?? {} };
|
|
1780
|
+
const out = {};
|
|
1781
|
+
for (const [name, src] of Object.entries(doc.sources)) {
|
|
1782
|
+
const srcPath = isAbsolute(src.path) ? src.path : resolve2(jobDir, src.path);
|
|
1783
|
+
if (!existsSync6(srcPath)) continue;
|
|
1784
|
+
const dur = src.durationS ?? await mediaDuration(srcPath);
|
|
1785
|
+
out[name] = await detectSilences(srcPath, dopts.noiseDb, dopts.minSilence, dur);
|
|
1786
|
+
}
|
|
1787
|
+
return out;
|
|
1788
|
+
}
|
|
1789
|
+
function statAssetsForDoc(jobDir, doc) {
|
|
1790
|
+
const out = {};
|
|
1791
|
+
const statRef = (ref) => {
|
|
1792
|
+
if (ref !== void 0 && !(ref in out)) {
|
|
1793
|
+
out[ref] = existsSync6(isAbsolute(ref) ? ref : resolve2(jobDir, ref));
|
|
1794
|
+
}
|
|
1795
|
+
};
|
|
1796
|
+
for (const ov of doc.overlays) statRef(ov.asset?.ref);
|
|
1797
|
+
for (const a of doc.audio) statRef(a.asset.ref);
|
|
1798
|
+
return out;
|
|
1799
|
+
}
|
|
1800
|
+
async function compileRemote(client, jobDir, loaded) {
|
|
1801
|
+
if (!loaded.doc) throw new Error("compileRemote requires a parsed timeline");
|
|
1802
|
+
const silences = await detectSilencesForDoc(jobDir, loaded.doc);
|
|
1803
|
+
const assets = statAssetsForDoc(jobDir, loaded.doc);
|
|
1804
|
+
const res = await client.compileRemote({
|
|
1805
|
+
timeline: loaded.raw,
|
|
1806
|
+
...Object.keys(silences).length > 0 ? { silences_by_source: silences } : {},
|
|
1807
|
+
...Object.keys(assets).length > 0 ? { assets_present: assets } : {}
|
|
1808
|
+
});
|
|
1809
|
+
const dir = join8(jobDir, "resolved");
|
|
1810
|
+
mkdirSync6(dir, { recursive: true });
|
|
1811
|
+
const out = { ok: res.ok === true, diagnosticsPath: diagnosticsPath2(jobDir), response: res };
|
|
1812
|
+
writeFileSync5(out.diagnosticsPath, JSON.stringify(res.diagnostics ?? { errors: [], warnings: [], dropped: [] }, null, 2) + "\n", "utf8");
|
|
1813
|
+
if (res.ok === true && res.plan !== void 0 && res.plan !== null) {
|
|
1814
|
+
out.planPath = planPath2(jobDir);
|
|
1815
|
+
writeFileSync5(out.planPath, JSON.stringify(res.plan, null, 2) + "\n", "utf8");
|
|
1816
|
+
}
|
|
1817
|
+
if (res.ok === true && res.legacy_edl !== void 0 && res.legacy_edl !== null) {
|
|
1818
|
+
out.edlPath = join8(jobDir, "edl.json");
|
|
1819
|
+
writeFileSync5(out.edlPath, JSON.stringify(res.legacy_edl, null, 2) + "\n", "utf8");
|
|
1820
|
+
}
|
|
1821
|
+
return out;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// src/cloud/verify2.ts
|
|
1825
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
1826
|
+
import { join as join9 } from "path";
|
|
1827
|
+
async function verifyCloud(client, jobDir, opts = {}) {
|
|
1828
|
+
const verbose = opts.verbose !== false;
|
|
1829
|
+
const log = (line) => {
|
|
1830
|
+
if (verbose) process.stderr.write(line + "\n");
|
|
1831
|
+
};
|
|
1832
|
+
const preview = join9(jobDir, "preview.mp4");
|
|
1833
|
+
if (!existsSync7(preview)) {
|
|
1834
|
+
throw new Error(`preview not found (render first, e.g. \`video-editing cloud render --mode preview --wait\`): ${preview}`);
|
|
1835
|
+
}
|
|
1836
|
+
log(" extracting preview audio");
|
|
1837
|
+
const { bytes, durationS } = await extractGuardedWav(preview);
|
|
1838
|
+
const wavPath = "audio/preview.wav";
|
|
1839
|
+
log(` uploading ${wavPath} (${(bytes.length / (1024 * 1024)).toFixed(1)} MB)`);
|
|
1840
|
+
await uploadVfsBytes(client, wavPath, bytes, "audio/wav");
|
|
1841
|
+
let edl;
|
|
1842
|
+
const edlPath = join9(jobDir, "edl.json");
|
|
1843
|
+
if (existsSync7(edlPath)) {
|
|
1844
|
+
try {
|
|
1845
|
+
edl = JSON.parse(readFileSync6(edlPath, "utf8"));
|
|
1846
|
+
} catch {
|
|
1847
|
+
edl = void 0;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
const { runId } = await client.verifyStart({
|
|
1851
|
+
wav_path: wavPath,
|
|
1852
|
+
preview_duration_s: durationS,
|
|
1853
|
+
...opts.full ? { mode: "full" } : {},
|
|
1854
|
+
...opts.windowS !== void 0 ? { window_s: opts.windowS } : {},
|
|
1855
|
+
...edl !== void 0 ? { edl } : {}
|
|
1856
|
+
});
|
|
1857
|
+
log(` verify run: ${runId}`);
|
|
1858
|
+
const state = await pollRun(client, runId, {
|
|
1859
|
+
intervalMs: opts.pollIntervalMs,
|
|
1860
|
+
onEvent: (ev) => log(` ${formatRunEvent(ev)}`)
|
|
1861
|
+
});
|
|
1862
|
+
assertRunCompleted(state, "verify");
|
|
1863
|
+
await pullFiles(client, jobDir, { only: ["verify.json"] });
|
|
1864
|
+
const verifyPath = join9(jobDir, "verify.json");
|
|
1865
|
+
let verdict = null;
|
|
1866
|
+
let findings = null;
|
|
1867
|
+
if (existsSync7(verifyPath)) {
|
|
1868
|
+
try {
|
|
1869
|
+
const v = JSON.parse(readFileSync6(verifyPath, "utf8"));
|
|
1870
|
+
verdict = typeof v.verdict === "string" ? v.verdict : null;
|
|
1871
|
+
findings = Array.isArray(v.joins) ? v.joins.length : null;
|
|
1872
|
+
} catch {
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
return { runId, verifyPath, verdict, findings };
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/cloud/compose2.ts
|
|
1879
|
+
import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync7, statSync as statSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
1880
|
+
import { dirname as dirname5, join as join10, resolve as resolvePath2 } from "path";
|
|
1881
|
+
function stale(out, src) {
|
|
1882
|
+
return !existsSync8(out) || statSync4(out).mtimeMs < statSync4(src).mtimeMs;
|
|
1883
|
+
}
|
|
1884
|
+
async function buildMediaEntry(jobDir, compDir, entry, opts) {
|
|
1885
|
+
const dest = join10(compDir, ...entry.src.split("/").filter((p) => p !== "" && p !== "." && p !== ".."));
|
|
1886
|
+
mkdirSync7(dirname5(dest), { recursive: true });
|
|
1887
|
+
if (entry.role === "video" || entry.role === "source") {
|
|
1888
|
+
const master = join10(jobDir, "vertical_src.mp4");
|
|
1889
|
+
if (!existsSync8(master)) {
|
|
1890
|
+
throw new Error(
|
|
1891
|
+
`vertical master not found: ${master} \u2014 bake it locally (\`video-editing init --source <video> --master\`) or pull it`
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
if (stale(dest, master)) {
|
|
1895
|
+
await run([
|
|
1896
|
+
"ffmpeg",
|
|
1897
|
+
"-y",
|
|
1898
|
+
"-hide_banner",
|
|
1899
|
+
"-loglevel",
|
|
1900
|
+
"error",
|
|
1901
|
+
"-stats",
|
|
1902
|
+
"-i",
|
|
1903
|
+
master,
|
|
1904
|
+
"-c:v",
|
|
1905
|
+
"libx264",
|
|
1906
|
+
"-preset",
|
|
1907
|
+
"veryfast",
|
|
1908
|
+
"-crf",
|
|
1909
|
+
String(opts.proxyCrf),
|
|
1910
|
+
// Dense keyframes: HyperFrames frame-extracts per clip; sparse GOPs
|
|
1911
|
+
// (default ~8s) break seeks on sub-second segments.
|
|
1912
|
+
"-r",
|
|
1913
|
+
"30",
|
|
1914
|
+
"-g",
|
|
1915
|
+
"30",
|
|
1916
|
+
"-keyint_min",
|
|
1917
|
+
"30",
|
|
1918
|
+
"-movflags",
|
|
1919
|
+
"+faststart",
|
|
1920
|
+
"-maxrate",
|
|
1921
|
+
"6M",
|
|
1922
|
+
"-bufsize",
|
|
1923
|
+
"12M",
|
|
1924
|
+
"-c:a",
|
|
1925
|
+
"aac",
|
|
1926
|
+
"-b:a",
|
|
1927
|
+
"160k",
|
|
1928
|
+
"-movflags",
|
|
1929
|
+
"+faststart",
|
|
1930
|
+
dest
|
|
1931
|
+
]);
|
|
1932
|
+
} else {
|
|
1933
|
+
process.stderr.write(" proxy up to date; skipping re-encode\n");
|
|
1934
|
+
}
|
|
1935
|
+
return dest;
|
|
1936
|
+
}
|
|
1937
|
+
if (entry.role === "audio") {
|
|
1938
|
+
const preview = join10(jobDir, "preview.mp4");
|
|
1939
|
+
if (existsSync8(preview)) {
|
|
1940
|
+
if (stale(dest, preview)) {
|
|
1941
|
+
await run([
|
|
1942
|
+
"ffmpeg",
|
|
1943
|
+
"-y",
|
|
1944
|
+
"-hide_banner",
|
|
1945
|
+
"-loglevel",
|
|
1946
|
+
"error",
|
|
1947
|
+
"-stats",
|
|
1948
|
+
"-i",
|
|
1949
|
+
preview,
|
|
1950
|
+
"-vn",
|
|
1951
|
+
"-c:a",
|
|
1952
|
+
"aac",
|
|
1953
|
+
"-b:a",
|
|
1954
|
+
"160k",
|
|
1955
|
+
dest
|
|
1956
|
+
]);
|
|
1957
|
+
} else {
|
|
1958
|
+
process.stderr.write(" cut audio up to date; skipping re-extract\n");
|
|
1959
|
+
}
|
|
1960
|
+
return dest;
|
|
1961
|
+
}
|
|
1962
|
+
const master = join10(jobDir, "vertical_src.mp4");
|
|
1963
|
+
const planPath = join10(jobDir, "resolved", "plan.json");
|
|
1964
|
+
if (!existsSync8(master) || !existsSync8(planPath)) {
|
|
1965
|
+
throw new Error(
|
|
1966
|
+
`cut audio needs either <job>/preview.mp4 or vertical_src.mp4 + resolved/plan.json (compile first)`
|
|
1967
|
+
);
|
|
1968
|
+
}
|
|
1969
|
+
const plan = JSON.parse(readFileSync7(planPath, "utf8"));
|
|
1970
|
+
const segs = (plan.segments ?? []).filter(
|
|
1971
|
+
(x) => typeof x.srcStart === "number" && typeof x.srcEnd === "number" && x.srcEnd > x.srcStart
|
|
1972
|
+
);
|
|
1973
|
+
if (segs.length === 0) throw new Error("resolved/plan.json has no usable segments for cut audio");
|
|
1974
|
+
const trims = segs.map((x, i) => `[0:a]atrim=start=${x.srcStart}:end=${x.srcEnd},asetpts=PTS-STARTPTS[a${i}]`).join(";");
|
|
1975
|
+
const labels = segs.map((_, i) => `[a${i}]`).join("");
|
|
1976
|
+
const filter = `${trims};${labels}concat=n=${segs.length}:v=0:a=1[out]`;
|
|
1977
|
+
await run([
|
|
1978
|
+
"ffmpeg",
|
|
1979
|
+
"-y",
|
|
1980
|
+
"-hide_banner",
|
|
1981
|
+
"-loglevel",
|
|
1982
|
+
"error",
|
|
1983
|
+
"-stats",
|
|
1984
|
+
"-i",
|
|
1985
|
+
master,
|
|
1986
|
+
"-filter_complex",
|
|
1987
|
+
filter,
|
|
1988
|
+
"-map",
|
|
1989
|
+
"[out]",
|
|
1990
|
+
"-c:a",
|
|
1991
|
+
"aac",
|
|
1992
|
+
"-b:a",
|
|
1993
|
+
"160k",
|
|
1994
|
+
dest
|
|
1995
|
+
]);
|
|
1996
|
+
return dest;
|
|
1997
|
+
}
|
|
1998
|
+
const ref = entry.src.replace(/^media\//, "");
|
|
1999
|
+
const srcPath = resolvePath2(jobDir, ref);
|
|
2000
|
+
if (!existsSync8(srcPath)) {
|
|
2001
|
+
throw new Error(`asset not found: ${ref} (expected at ${srcPath}) \u2014 asset refs are job-dir-relative paths`);
|
|
2002
|
+
}
|
|
2003
|
+
copyFileSync(srcPath, dest);
|
|
2004
|
+
return dest;
|
|
2005
|
+
}
|
|
2006
|
+
async function composeCloud(client, jobDir, opts = {}) {
|
|
2007
|
+
const quality = opts.quality ?? "standard";
|
|
2008
|
+
if (!["draft", "standard", "high"].includes(quality)) throw new Error("--quality must be draft|standard|high");
|
|
2009
|
+
const fps = opts.fps ?? 30;
|
|
2010
|
+
const proxyCrf = opts.proxyCrf ?? 23;
|
|
2011
|
+
const res = await client.getCompose();
|
|
2012
|
+
const compDir = join10(jobDir, "composition");
|
|
2013
|
+
mkdirSync7(join10(compDir, "media"), { recursive: true });
|
|
2014
|
+
const entry = join10(compDir, "index.html");
|
|
2015
|
+
writeFileSync6(entry, res.html, "utf8");
|
|
2016
|
+
for (const m of res.media) {
|
|
2017
|
+
await buildMediaEntry(jobDir, compDir, m, { proxyCrf });
|
|
2018
|
+
}
|
|
2019
|
+
const out = resolvePath2(opts.out ?? join10(jobDir, "preview.mp4"));
|
|
2020
|
+
const renderCmd = ["hyperframes", "render", "--quality", quality, "--fps", String(fps), "--output", out];
|
|
2021
|
+
const result = {
|
|
2022
|
+
compositionDir: compDir,
|
|
2023
|
+
entry,
|
|
2024
|
+
counts: res.counts,
|
|
2025
|
+
media: res.media,
|
|
2026
|
+
renderCmd,
|
|
2027
|
+
rendered: false
|
|
2028
|
+
};
|
|
2029
|
+
if (!opts.render) {
|
|
2030
|
+
if (opts.verbose !== false) {
|
|
2031
|
+
process.stderr.write(`render locally: cd ${shown([compDir])} && ${shown(renderCmd)}
|
|
2032
|
+
`);
|
|
2033
|
+
}
|
|
2034
|
+
return result;
|
|
2035
|
+
}
|
|
2036
|
+
const probe = runSync(["hyperframes", "--version"], { check: false, echo: false });
|
|
2037
|
+
if (probe.code !== 0) {
|
|
2038
|
+
throw new Error("`hyperframes` CLI not found on PATH \u2014 install it with `npm install -g hyperframes`");
|
|
2039
|
+
}
|
|
2040
|
+
const render = await run(renderCmd, { cwd: compDir, check: false });
|
|
2041
|
+
result.renderExit = render.code;
|
|
2042
|
+
result.rendered = render.code === 0;
|
|
2043
|
+
if (render.code === 0) result.out = out;
|
|
2044
|
+
return result;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
// src/cloud/qa-local.ts
|
|
2048
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync8, readdirSync as readdirSync2, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
2049
|
+
import { join as join11 } from "path";
|
|
2050
|
+
function qaFramesArgs(win, opts = {}) {
|
|
2051
|
+
const dur = Math.max(0.05, win.end - win.start);
|
|
2052
|
+
const frames = Math.max(1, Math.round(opts.frames ?? 10));
|
|
2053
|
+
const cols = Math.max(1, Math.round(opts.cols ?? 5));
|
|
2054
|
+
const rows = Math.ceil(frames / cols);
|
|
2055
|
+
const fps = frames / dur;
|
|
2056
|
+
const width = Math.max(64, Math.round(opts.frameWidth ?? 216));
|
|
2057
|
+
return [
|
|
2058
|
+
"ffmpeg",
|
|
2059
|
+
"-y",
|
|
2060
|
+
"-hide_banner",
|
|
2061
|
+
"-loglevel",
|
|
2062
|
+
"error",
|
|
2063
|
+
"-ss",
|
|
2064
|
+
win.start.toFixed(3),
|
|
2065
|
+
"-t",
|
|
2066
|
+
dur.toFixed(3),
|
|
2067
|
+
"-i",
|
|
2068
|
+
win.video,
|
|
2069
|
+
"-frames:v",
|
|
2070
|
+
"1",
|
|
2071
|
+
"-vf",
|
|
2072
|
+
`fps=${fps.toFixed(6)},scale=${width}:-2,tile=${cols}x${rows}`,
|
|
2073
|
+
win.output
|
|
2074
|
+
];
|
|
2075
|
+
}
|
|
2076
|
+
function qaWaveformArgs(win, opts = {}) {
|
|
2077
|
+
const dur = Math.max(0.05, win.end - win.start);
|
|
2078
|
+
const w = Math.max(64, Math.round(opts.width ?? 1200));
|
|
2079
|
+
const h = Math.max(32, Math.round(opts.height ?? 240));
|
|
2080
|
+
return [
|
|
2081
|
+
"ffmpeg",
|
|
2082
|
+
"-y",
|
|
2083
|
+
"-hide_banner",
|
|
2084
|
+
"-loglevel",
|
|
2085
|
+
"error",
|
|
2086
|
+
"-ss",
|
|
2087
|
+
win.start.toFixed(3),
|
|
2088
|
+
"-t",
|
|
2089
|
+
dur.toFixed(3),
|
|
2090
|
+
"-i",
|
|
2091
|
+
win.video,
|
|
2092
|
+
"-filter_complex",
|
|
2093
|
+
`showwavespic=s=${w}x${h}:split_channels=0`,
|
|
2094
|
+
"-frames:v",
|
|
2095
|
+
"1",
|
|
2096
|
+
win.output
|
|
2097
|
+
];
|
|
2098
|
+
}
|
|
2099
|
+
function qaWordsForWindow(transcriptJson, start, end) {
|
|
2100
|
+
const data = JSON.parse(transcriptJson);
|
|
2101
|
+
const out = [];
|
|
2102
|
+
for (const w of data.words ?? []) {
|
|
2103
|
+
if ((w.type ?? "word") !== "word") continue;
|
|
2104
|
+
if (typeof w.start !== "number" || typeof w.end !== "number") continue;
|
|
2105
|
+
if (w.end < start || w.start > end) continue;
|
|
2106
|
+
const text = typeof w.text === "string" ? w.text.trim() : "";
|
|
2107
|
+
if (!text) continue;
|
|
2108
|
+
out.push({ t: w.start, end: w.end, text });
|
|
2109
|
+
}
|
|
2110
|
+
return out;
|
|
2111
|
+
}
|
|
2112
|
+
function findLocalTranscript(jobDir, stem) {
|
|
2113
|
+
const dir = join11(jobDir, "transcripts");
|
|
2114
|
+
if (stem) {
|
|
2115
|
+
const direct = join11(dir, `${stem}.json`);
|
|
2116
|
+
if (existsSync9(direct)) return direct;
|
|
2117
|
+
}
|
|
2118
|
+
if (!existsSync9(dir)) return null;
|
|
2119
|
+
const all = readdirSync2(dir).filter((f) => f.endsWith(".json"));
|
|
2120
|
+
return all.length === 1 ? join11(dir, all[0]) : null;
|
|
2121
|
+
}
|
|
2122
|
+
async function qaFramesLocal(jobDir, args) {
|
|
2123
|
+
const dir = join11(jobDir, "qa");
|
|
2124
|
+
mkdirSync8(dir, { recursive: true });
|
|
2125
|
+
const png = join11(dir, `frames_${args.start}_${args.end}.png`);
|
|
2126
|
+
await run(qaFramesArgs({ video: args.video, start: args.start, end: args.end, output: png }, args), {
|
|
2127
|
+
capture: true,
|
|
2128
|
+
echo: false
|
|
2129
|
+
});
|
|
2130
|
+
return { png };
|
|
2131
|
+
}
|
|
2132
|
+
async function qaWaveformLocal(jobDir, args) {
|
|
2133
|
+
const dir = join11(jobDir, "qa");
|
|
2134
|
+
mkdirSync8(dir, { recursive: true });
|
|
2135
|
+
const png = join11(dir, `waveform_${args.start}_${args.end}.png`);
|
|
2136
|
+
await run(qaWaveformArgs({ video: args.video, start: args.start, end: args.end, output: png }), {
|
|
2137
|
+
capture: true,
|
|
2138
|
+
echo: false
|
|
2139
|
+
});
|
|
2140
|
+
const result = { png };
|
|
2141
|
+
const tpath = findLocalTranscript(jobDir, args.stem);
|
|
2142
|
+
if (tpath) {
|
|
2143
|
+
const words = qaWordsForWindow(readFileSync8(tpath, "utf8"), args.start, args.end);
|
|
2144
|
+
const wordsPath = join11(dir, `waveform_${args.start}_${args.end}.words.json`);
|
|
2145
|
+
writeFileSync7(wordsPath, JSON.stringify(words, null, 2) + "\n", "utf8");
|
|
2146
|
+
result.wordsPath = wordsPath;
|
|
2147
|
+
result.words = words.length;
|
|
2148
|
+
}
|
|
2149
|
+
return result;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// src/cloud/review2.ts
|
|
2153
|
+
import { createHash as createHash2 } from "crypto";
|
|
2154
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
|
|
2155
|
+
import { basename, isAbsolute as isAbsolute2, join as join12 } from "path";
|
|
2156
|
+
function reviewDir2(jobDir) {
|
|
2157
|
+
return join12(jobDir, "review");
|
|
2158
|
+
}
|
|
2159
|
+
function roundJsonPath2(jobDir) {
|
|
2160
|
+
return join12(reviewDir2(jobDir), "round.json");
|
|
2161
|
+
}
|
|
2162
|
+
function instructionPath2(jobDir) {
|
|
2163
|
+
return join12(reviewDir2(jobDir), "instruction.md");
|
|
2164
|
+
}
|
|
2165
|
+
function assetsCachePath2(jobDir) {
|
|
2166
|
+
return join12(reviewDir2(jobDir), "assets.json");
|
|
2167
|
+
}
|
|
2168
|
+
async function pullReview2(client, jobDir, opts = {}) {
|
|
2169
|
+
const session = await client.getReview();
|
|
2170
|
+
mkdirSync9(reviewDir2(jobDir), { recursive: true });
|
|
2171
|
+
const round = session.activeRound;
|
|
2172
|
+
if (round) {
|
|
2173
|
+
writeFileSync8(
|
|
2174
|
+
roundJsonPath2(jobDir),
|
|
2175
|
+
JSON.stringify(
|
|
2176
|
+
{
|
|
2177
|
+
video_id: session.videoId,
|
|
2178
|
+
round_id: round.roundId,
|
|
2179
|
+
run_id: round.runId,
|
|
2180
|
+
seq: round.seq,
|
|
2181
|
+
mode: round.mode,
|
|
2182
|
+
status: round.status,
|
|
2183
|
+
pulled_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2184
|
+
changes: round.changes
|
|
2185
|
+
},
|
|
2186
|
+
null,
|
|
2187
|
+
2
|
|
2188
|
+
) + "\n",
|
|
2189
|
+
"utf8"
|
|
2190
|
+
);
|
|
2191
|
+
writeFileSync8(instructionPath2(jobDir), round.instruction + "\n", "utf8");
|
|
2192
|
+
} else {
|
|
2193
|
+
writeFileSync8(instructionPath2(jobDir), "No active review round for this video.\n", "utf8");
|
|
2194
|
+
}
|
|
2195
|
+
let wroteTimeline = false;
|
|
2196
|
+
const tlPath = timelineJsonPath2(jobDir);
|
|
2197
|
+
if (session.timeline !== null && session.timeline !== void 0 && !existsSync10(tlPath)) {
|
|
2198
|
+
writeFileSync8(tlPath, JSON.stringify(session.timeline, null, 2) + "\n", "utf8");
|
|
2199
|
+
wroteTimeline = true;
|
|
2200
|
+
}
|
|
2201
|
+
let downloadedSource = null;
|
|
2202
|
+
if (opts.withSource && session.sourceUrl) {
|
|
2203
|
+
const dest = join12(jobDir, "source.mp4");
|
|
2204
|
+
if (!existsSync10(dest)) {
|
|
2205
|
+
const dl = await client.download(session.sourceUrl, dest);
|
|
2206
|
+
downloadedSource = dl.path;
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
return { jobDir, round, wroteTimeline, downloadedSource };
|
|
2210
|
+
}
|
|
2211
|
+
function resolveRunId2(jobDir, flagOverride) {
|
|
2212
|
+
if (typeof flagOverride === "string" && flagOverride.trim() !== "") return flagOverride.trim();
|
|
2213
|
+
const path = roundJsonPath2(jobDir);
|
|
2214
|
+
if (existsSync10(path)) {
|
|
2215
|
+
try {
|
|
2216
|
+
const runId = JSON.parse(readFileSync9(path, "utf8")).run_id;
|
|
2217
|
+
if (typeof runId === "string" && runId !== "") return runId;
|
|
2218
|
+
} catch {
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
throw new Error(`missing run id \u2014 pass --run-id, or run \`review pull\` first (no run_id in ${path})`);
|
|
2222
|
+
}
|
|
2223
|
+
var REVIEW_STAGES2 = [
|
|
2224
|
+
"provisioning",
|
|
2225
|
+
"transcribing",
|
|
2226
|
+
"edl",
|
|
2227
|
+
"render_preview",
|
|
2228
|
+
"qc",
|
|
2229
|
+
"render_final",
|
|
2230
|
+
"uploading",
|
|
2231
|
+
"log"
|
|
2232
|
+
];
|
|
2233
|
+
async function pushStage2(client, runId, stage, message) {
|
|
2234
|
+
if (!REVIEW_STAGES2.includes(stage)) {
|
|
2235
|
+
throw new Error(`invalid --stage "${stage}" \u2014 expected one of: ${REVIEW_STAGES2.join(", ")}`);
|
|
2236
|
+
}
|
|
2237
|
+
await client.pushReview({
|
|
2238
|
+
run_id: runId,
|
|
2239
|
+
type: stage,
|
|
2240
|
+
...message !== void 0 ? { payload: { message } } : {}
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
var ASSET_CONTENT_TYPES2 = {
|
|
2244
|
+
".png": "image/png",
|
|
2245
|
+
".jpg": "image/jpeg",
|
|
2246
|
+
".jpeg": "image/jpeg",
|
|
2247
|
+
".webp": "image/webp",
|
|
2248
|
+
".gif": "image/gif",
|
|
2249
|
+
".mp3": "audio/mpeg",
|
|
2250
|
+
".m4a": "audio/mp4",
|
|
2251
|
+
".wav": "audio/wav"
|
|
2252
|
+
};
|
|
2253
|
+
function assetContentType2(ref) {
|
|
2254
|
+
const m = /\.[^./\\]+$/.exec(ref.toLowerCase());
|
|
2255
|
+
return (m !== null ? ASSET_CONTENT_TYPES2[m[0]] : void 0) ?? "application/octet-stream";
|
|
2256
|
+
}
|
|
2257
|
+
function collectPlanAssetRefs2(plan) {
|
|
2258
|
+
const refs = [];
|
|
2259
|
+
const events = plan?.events;
|
|
2260
|
+
if (!Array.isArray(events)) return refs;
|
|
2261
|
+
for (const ev of events) {
|
|
2262
|
+
const e = ev;
|
|
2263
|
+
if (!e || typeof e !== "object") continue;
|
|
2264
|
+
let ref;
|
|
2265
|
+
if (e.kind === "overlay") ref = e.overlay?.asset?.ref;
|
|
2266
|
+
else if (e.kind === "audio") ref = e.audio?.asset?.ref;
|
|
2267
|
+
if (typeof ref === "string" && ref !== "" && !refs.includes(ref)) refs.push(ref);
|
|
2268
|
+
}
|
|
2269
|
+
return refs;
|
|
2270
|
+
}
|
|
2271
|
+
async function syncAssets2(client, jobDir, plan) {
|
|
2272
|
+
const refs = collectPlanAssetRefs2(plan);
|
|
2273
|
+
if (refs.length === 0) return {};
|
|
2274
|
+
const paths = /* @__PURE__ */ new Map();
|
|
2275
|
+
for (const ref of refs) {
|
|
2276
|
+
const abs = isAbsolute2(ref) ? ref : join12(jobDir, ref);
|
|
2277
|
+
if (!existsSync10(abs)) {
|
|
2278
|
+
throw new Error(
|
|
2279
|
+
`asset not found: ${ref} (expected at ${abs}) \u2014 asset refs are job-dir-relative paths; place the file before pushing`
|
|
2280
|
+
);
|
|
2281
|
+
}
|
|
2282
|
+
paths.set(ref, abs);
|
|
2283
|
+
}
|
|
2284
|
+
let cache = {};
|
|
2285
|
+
if (existsSync10(assetsCachePath2(jobDir))) {
|
|
2286
|
+
try {
|
|
2287
|
+
const raw = JSON.parse(readFileSync9(assetsCachePath2(jobDir), "utf8"));
|
|
2288
|
+
for (const [ref, v] of Object.entries(raw ?? {})) {
|
|
2289
|
+
const e = v;
|
|
2290
|
+
if (e && typeof e.sha256 === "string" && typeof e.asset_id === "string") {
|
|
2291
|
+
cache[ref] = { sha256: e.sha256, asset_id: e.asset_id };
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
} catch {
|
|
2295
|
+
cache = {};
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
const manifest = {};
|
|
2299
|
+
let dirty = false;
|
|
2300
|
+
for (const ref of refs) {
|
|
2301
|
+
const bytes = readFileSync9(paths.get(ref));
|
|
2302
|
+
const sha256 = "sha256:" + createHash2("sha256").update(bytes).digest("hex");
|
|
2303
|
+
const hit = cache[ref];
|
|
2304
|
+
if (hit !== void 0 && hit.sha256 === sha256) {
|
|
2305
|
+
manifest[ref] = hit.asset_id;
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
const contentType = assetContentType2(ref);
|
|
2309
|
+
const created = await client.createAsset({ kind: "timeline", contentType, filename: basename(ref) });
|
|
2310
|
+
await client.upload(created.uploadUrl, bytes, contentType);
|
|
2311
|
+
cache[ref] = { sha256, asset_id: created.assetId };
|
|
2312
|
+
manifest[ref] = created.assetId;
|
|
2313
|
+
dirty = true;
|
|
2314
|
+
}
|
|
2315
|
+
if (dirty) {
|
|
2316
|
+
mkdirSync9(reviewDir2(jobDir), { recursive: true });
|
|
2317
|
+
writeFileSync8(assetsCachePath2(jobDir), JSON.stringify(cache, null, 2) + "\n", "utf8");
|
|
2318
|
+
}
|
|
2319
|
+
return manifest;
|
|
2320
|
+
}
|
|
2321
|
+
async function compileViaServer(client, jobDir) {
|
|
2322
|
+
const loaded = loadTimeline2(jobDir);
|
|
2323
|
+
if (!loaded.doc) return { ok: false, errors: loaded.errors, diagnostics: null };
|
|
2324
|
+
const res = await compileRemote(client, jobDir, loaded);
|
|
2325
|
+
if (!res.ok) {
|
|
2326
|
+
const diag = res.response.diagnostics;
|
|
2327
|
+
return { ok: false, errors: diag?.errors ?? [], diagnostics: res.response.diagnostics ?? null };
|
|
2328
|
+
}
|
|
2329
|
+
const plan = res.response.plan;
|
|
2330
|
+
const timelineHash = plan !== null && typeof plan === "object" && typeof plan.timelineHash === "string" ? plan.timelineHash : null;
|
|
2331
|
+
return { ok: true, raw: loaded.raw, plan, legacyEdl: res.response.legacy_edl ?? null, timelineHash };
|
|
2332
|
+
}
|
|
2333
|
+
async function pushTimeline2(client, jobDir, runId) {
|
|
2334
|
+
const compiled = await compileViaServer(client, jobDir);
|
|
2335
|
+
if (!compiled.ok) return compiled;
|
|
2336
|
+
const assets = await syncAssets2(client, jobDir, compiled.plan);
|
|
2337
|
+
await client.pushReview({
|
|
2338
|
+
run_id: runId,
|
|
2339
|
+
type: "timeline_update",
|
|
2340
|
+
payload: {
|
|
2341
|
+
timeline: compiled.raw,
|
|
2342
|
+
plan: compiled.plan,
|
|
2343
|
+
...compiled.timelineHash !== null ? { timeline_hash: compiled.timelineHash } : {},
|
|
2344
|
+
...Object.keys(assets).length > 0 ? { assets } : {}
|
|
2345
|
+
}
|
|
2346
|
+
});
|
|
2347
|
+
return { ok: true, plan: compiled.plan, timelineHash: compiled.timelineHash, assets };
|
|
2348
|
+
}
|
|
2349
|
+
function readOutcomesFile2(path) {
|
|
2350
|
+
if (!existsSync10(path)) throw new Error(`outcomes file not found: ${path}`);
|
|
2351
|
+
let raw;
|
|
2352
|
+
try {
|
|
2353
|
+
raw = JSON.parse(readFileSync9(path, "utf8"));
|
|
2354
|
+
} catch (err) {
|
|
2355
|
+
throw new Error(`outcomes file is not valid JSON: ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2356
|
+
}
|
|
2357
|
+
const arr = Array.isArray(raw) ? raw : raw?.outcomes;
|
|
2358
|
+
if (!Array.isArray(arr)) {
|
|
2359
|
+
throw new Error(`outcomes file must be an array (or {outcomes: [...]}) of {change_id, ok, note?}: ${path}`);
|
|
2360
|
+
}
|
|
2361
|
+
return arr.map((o, i) => {
|
|
2362
|
+
const r = o ?? {};
|
|
2363
|
+
if (typeof r.change_id !== "string" || typeof r.ok !== "boolean" || r.note !== void 0 && typeof r.note !== "string") {
|
|
2364
|
+
throw new Error(`outcomes[${i}] must be {change_id: string, ok: boolean, note?: string}: ${JSON.stringify(o)}`);
|
|
2365
|
+
}
|
|
2366
|
+
return { change_id: r.change_id, ok: r.ok, ...r.note !== void 0 ? { note: r.note } : {} };
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
async function pushComplete2(client, jobDir, runId, opts) {
|
|
2370
|
+
const outcomes = opts.outcomesPath ? readOutcomesFile2(opts.outcomesPath) : void 0;
|
|
2371
|
+
const compiled = await compileViaServer(client, jobDir);
|
|
2372
|
+
if (!compiled.ok) return compiled;
|
|
2373
|
+
const assets = await syncAssets2(client, jobDir, compiled.plan);
|
|
2374
|
+
await client.pushReview({
|
|
2375
|
+
run_id: runId,
|
|
2376
|
+
type: "completed",
|
|
2377
|
+
payload: {
|
|
2378
|
+
edl: compiled.legacyEdl,
|
|
2379
|
+
timeline: compiled.raw,
|
|
2380
|
+
plan: compiled.plan,
|
|
2381
|
+
duration_s: opts.durationS,
|
|
2382
|
+
...outcomes !== void 0 ? { outcomes } : {},
|
|
2383
|
+
...Object.keys(assets).length > 0 ? { assets } : {}
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
return { ok: true, plan: compiled.plan, durationS: opts.durationS, outcomes, assets };
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// src/public/config.ts
|
|
2390
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync10, readFileSync as readFileSync10, writeFileSync as writeFileSync9 } from "fs";
|
|
2391
|
+
import { homedir as homedir2 } from "os";
|
|
2392
|
+
import { join as join13 } from "path";
|
|
2393
|
+
function publicConfigDir() {
|
|
2394
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
2395
|
+
const base = xdg && xdg.trim() ? xdg : join13(homedir2(), ".config");
|
|
2396
|
+
return join13(base, "video-editing");
|
|
2397
|
+
}
|
|
2398
|
+
function publicConfigEnvPath() {
|
|
2399
|
+
return join13(publicConfigDir(), ".env");
|
|
2400
|
+
}
|
|
2401
|
+
function parseEnvLines(text) {
|
|
2402
|
+
return text.split(/\r?\n/).map((raw) => {
|
|
2403
|
+
const line = raw.trim();
|
|
2404
|
+
if (!line || line.startsWith("#") || !line.includes("=")) return { raw };
|
|
2405
|
+
const eq = line.indexOf("=");
|
|
2406
|
+
const key = line.slice(0, eq).trim();
|
|
2407
|
+
let value = line.slice(eq + 1).trim();
|
|
2408
|
+
value = value.replace(/^"|"$/g, "").replace(/^'|'$/g, "").trim();
|
|
2409
|
+
return { raw, key, value };
|
|
2410
|
+
});
|
|
2411
|
+
}
|
|
2412
|
+
var MANAGED_KEYS = ["CLIPREADY_API_KEY", "CLIPREADY_API_BASE"];
|
|
2413
|
+
function loadPublicConfigEnv(env = process.env) {
|
|
2414
|
+
const path = publicConfigEnvPath();
|
|
2415
|
+
if (!existsSync11(path)) return;
|
|
2416
|
+
let text;
|
|
2417
|
+
try {
|
|
2418
|
+
text = readFileSync10(path, "utf8");
|
|
2419
|
+
} catch {
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
for (const line of parseEnvLines(text)) {
|
|
2423
|
+
if (!line.key || line.value === void 0) continue;
|
|
2424
|
+
if (!MANAGED_KEYS.includes(line.key)) continue;
|
|
2425
|
+
if (!env[line.key] || !String(env[line.key]).trim()) env[line.key] = line.value;
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
function savePublicConfigKey(key, value) {
|
|
2429
|
+
mkdirSync10(publicConfigDir(), { recursive: true });
|
|
2430
|
+
const path = publicConfigEnvPath();
|
|
2431
|
+
const existing = existsSync11(path) ? readFileSync10(path, "utf8") : "";
|
|
2432
|
+
const lines = existing.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
2433
|
+
const kept = lines.filter((l) => {
|
|
2434
|
+
const eq = l.indexOf("=");
|
|
2435
|
+
return eq < 0 || l.slice(0, eq).trim() !== key;
|
|
2436
|
+
});
|
|
2437
|
+
kept.push(`${key}=${value}`);
|
|
2438
|
+
writeFileSync9(path, kept.join("\n") + "\n", { mode: 384 });
|
|
2439
|
+
return path;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
export {
|
|
2443
|
+
requireFfmpeg,
|
|
2444
|
+
DEFAULT_TIMEOUT_MS,
|
|
2445
|
+
DEFAULT_DOWNLOAD_TIMEOUT_MS,
|
|
2446
|
+
resolveApiBase,
|
|
2447
|
+
resolveApiKey,
|
|
2448
|
+
resolveVideoId,
|
|
2449
|
+
stripTrailingSlash,
|
|
2450
|
+
resolveConfig,
|
|
2451
|
+
rendersUrl,
|
|
2452
|
+
renderUrl,
|
|
2453
|
+
reviewUrl,
|
|
2454
|
+
assetsUrl,
|
|
2455
|
+
meUrl,
|
|
2456
|
+
projectsUrl,
|
|
2457
|
+
projectVideosUrl,
|
|
2458
|
+
runUrl,
|
|
2459
|
+
filesUrl,
|
|
2460
|
+
transcribeUrl,
|
|
2461
|
+
compileUrl,
|
|
2462
|
+
resolveUrl,
|
|
2463
|
+
verifyUrl,
|
|
2464
|
+
composeUrl,
|
|
2465
|
+
parseRenderCreate,
|
|
2466
|
+
parseRenderState,
|
|
2467
|
+
isTerminalStatus,
|
|
2468
|
+
parseFileList,
|
|
2469
|
+
parseFileContent,
|
|
2470
|
+
parseFilePresign,
|
|
2471
|
+
parseRunState,
|
|
2472
|
+
isTerminalRunStatus,
|
|
2473
|
+
CloudHttpError,
|
|
2474
|
+
CloudClient,
|
|
2475
|
+
downloadTo,
|
|
2476
|
+
MAX_POLL_MS,
|
|
2477
|
+
defaultRenderOut,
|
|
2478
|
+
pollRender,
|
|
2479
|
+
bundledSkillDir,
|
|
2480
|
+
defaultSkillTarget,
|
|
2481
|
+
installSkill,
|
|
2482
|
+
jobJsonPath,
|
|
2483
|
+
writeJobFile,
|
|
2484
|
+
readJobFile,
|
|
2485
|
+
videoIdFromJob,
|
|
2486
|
+
initCloudJob,
|
|
2487
|
+
INLINE_MAX_BYTES,
|
|
2488
|
+
sha256Hex,
|
|
2489
|
+
sameSha,
|
|
2490
|
+
vfsLocalPath,
|
|
2491
|
+
toVfsPath,
|
|
2492
|
+
vfsContentType,
|
|
2493
|
+
pullFiles,
|
|
2494
|
+
pushFiles,
|
|
2495
|
+
uploadVfsBytes,
|
|
2496
|
+
MAX_RUN_POLL_MS,
|
|
2497
|
+
formatRunEvent,
|
|
2498
|
+
pollRun,
|
|
2499
|
+
assertRunCompleted,
|
|
2500
|
+
transcribeArtifactSet,
|
|
2501
|
+
extractGuardedWav,
|
|
2502
|
+
transcribeCloud,
|
|
2503
|
+
DEFAULT_DESILENCE,
|
|
2504
|
+
silenceDetectArgs,
|
|
2505
|
+
parseSilenceDetect,
|
|
2506
|
+
detectSilences,
|
|
2507
|
+
CAPTION_STYLE_DEFAULTS,
|
|
2508
|
+
timelineV2Schema,
|
|
2509
|
+
emptyDiagnostics,
|
|
2510
|
+
parseTimeline,
|
|
2511
|
+
validateTimeline,
|
|
2512
|
+
CAPABILITIES,
|
|
2513
|
+
usesFromDoc,
|
|
2514
|
+
checkCapabilities,
|
|
2515
|
+
timelineJsonPath2,
|
|
2516
|
+
planPath2,
|
|
2517
|
+
diagnosticsPath2,
|
|
2518
|
+
loadTimeline2,
|
|
2519
|
+
validateLocal,
|
|
2520
|
+
detectSilencesForDoc,
|
|
2521
|
+
statAssetsForDoc,
|
|
2522
|
+
compileRemote,
|
|
2523
|
+
verifyCloud,
|
|
2524
|
+
buildMediaEntry,
|
|
2525
|
+
composeCloud,
|
|
2526
|
+
qaFramesArgs,
|
|
2527
|
+
qaWaveformArgs,
|
|
2528
|
+
qaWordsForWindow,
|
|
2529
|
+
findLocalTranscript,
|
|
2530
|
+
qaFramesLocal,
|
|
2531
|
+
qaWaveformLocal,
|
|
2532
|
+
reviewDir2,
|
|
2533
|
+
roundJsonPath2,
|
|
2534
|
+
instructionPath2,
|
|
2535
|
+
pullReview2,
|
|
2536
|
+
resolveRunId2,
|
|
2537
|
+
REVIEW_STAGES2,
|
|
2538
|
+
pushStage2,
|
|
2539
|
+
assetContentType2,
|
|
2540
|
+
collectPlanAssetRefs2,
|
|
2541
|
+
syncAssets2,
|
|
2542
|
+
pushTimeline2,
|
|
2543
|
+
readOutcomesFile2,
|
|
2544
|
+
pushComplete2,
|
|
2545
|
+
publicConfigDir,
|
|
2546
|
+
publicConfigEnvPath,
|
|
2547
|
+
loadPublicConfigEnv,
|
|
2548
|
+
savePublicConfigKey
|
|
2549
|
+
};
|