video-editing 1.3.1 → 3.0.0

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