@clipit-ai/cli 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +24 -6
- package/bin/clipit.mjs +1088 -122
- package/package.json +1 -1
package/bin/clipit.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { createReadStream } from 'node:fs';
|
|
5
5
|
import { Transform } from 'node:stream';
|
|
@@ -9,7 +9,7 @@ import path from 'node:path';
|
|
|
9
9
|
import process from 'node:process';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
11
|
|
|
12
|
-
const VERSION = '0.2.
|
|
12
|
+
const VERSION = '0.2.6';
|
|
13
13
|
const DEFAULT_BASE_URL = 'https://clipit.dev';
|
|
14
14
|
const DEFAULT_SCOPES = [
|
|
15
15
|
'clippy_agent',
|
|
@@ -23,6 +23,7 @@ const DEFAULT_SCOPES = [
|
|
|
23
23
|
'broll_generation',
|
|
24
24
|
'audio_generation',
|
|
25
25
|
'video_alteration',
|
|
26
|
+
'social_publishing',
|
|
26
27
|
];
|
|
27
28
|
|
|
28
29
|
const GET_RETRY_DELAYS_MS = [500, 2000];
|
|
@@ -31,7 +32,24 @@ const RETRYABLE_GET_STATUSES = new Set([429, 502, 503, 504]);
|
|
|
31
32
|
const RECENT_LIMIT = 20;
|
|
32
33
|
const REMOTION_ESTIMATED_USD_PER_VIDEO_SECOND = 0.0015;
|
|
33
34
|
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
|
34
|
-
const
|
|
35
|
+
const VIDEO_UPLOAD_PART_CONCURRENCY = readPositiveIntegerEnv('CLIPIT_CLI_UPLOAD_CONCURRENCY', 4, 100);
|
|
36
|
+
const VIDEO_UPLOAD_PART_ATTEMPTS = 3;
|
|
37
|
+
const VIDEO_UPLOAD_PART_SIGN_BATCH = 100;
|
|
38
|
+
const VIDEO_UPLOAD_STALL_TIMEOUT_MS = readPositiveIntegerEnv(
|
|
39
|
+
'CLIPIT_CLI_UPLOAD_STALL_TIMEOUT_MS',
|
|
40
|
+
60_000,
|
|
41
|
+
10 * 60_000,
|
|
42
|
+
);
|
|
43
|
+
const VIDEO_UPLOAD_RETRY_BASE_MS = readPositiveIntegerEnv(
|
|
44
|
+
'CLIPIT_CLI_UPLOAD_RETRY_BASE_MS',
|
|
45
|
+
1_000,
|
|
46
|
+
10_000,
|
|
47
|
+
);
|
|
48
|
+
const API_REQUEST_TIMEOUT_MS = readPositiveIntegerEnv(
|
|
49
|
+
'CLIPIT_CLI_REQUEST_TIMEOUT_MS',
|
|
50
|
+
60_000,
|
|
51
|
+
5 * 60_000,
|
|
52
|
+
);
|
|
35
53
|
const MAX_CREDITS_ESTIMATE_MAP = Object.freeze({
|
|
36
54
|
'exports start': [{ operationType: 'lambda_render', provider: 'aws_lambda', modelId: 'remotion-4.0', metrics: 'remotion-render' }],
|
|
37
55
|
'thumbnails generate': [{ operationType: 'thumbnail_generation', provider: 'replicate', modelId: 'openai/gpt-image-2', metrics: 'one-generation' }],
|
|
@@ -202,9 +220,15 @@ const KNOWN_AGENT_TARGETS = ['codex', 'claude', 'hermes', 'generic'];
|
|
|
202
220
|
const TRUSTED_HOSTS = new Set(['clipit.dev', 'www.clipit.dev', 'localhost', '127.0.0.1', '::1', '[::1]']);
|
|
203
221
|
const AGENT_SKILL_META_FILENAME = 'SKILL.meta.json';
|
|
204
222
|
|
|
205
|
-
function readPositiveIntegerEnv(name, fallback) {
|
|
223
|
+
function readPositiveIntegerEnv(name, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
|
206
224
|
const parsed = Number(process.env[name]);
|
|
207
|
-
return Number.isFinite(parsed) && parsed > 0
|
|
225
|
+
return Number.isFinite(parsed) && parsed > 0
|
|
226
|
+
? Math.min(maximum, Math.max(1, Math.floor(parsed)))
|
|
227
|
+
: fallback;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function shellQuote(value) {
|
|
231
|
+
return `'${String(value).replace(/'/g, "'\"'\"'")}'`;
|
|
208
232
|
}
|
|
209
233
|
|
|
210
234
|
function configDir() {
|
|
@@ -239,6 +263,7 @@ function legacyProfile(config) {
|
|
|
239
263
|
baseUrl: config.baseUrl,
|
|
240
264
|
apiKey: config.apiKey,
|
|
241
265
|
keyInfo: config.keyInfo,
|
|
266
|
+
scope: config.scope,
|
|
242
267
|
loginSource: config.loginSource,
|
|
243
268
|
activeContext: config.activeContext,
|
|
244
269
|
recent: config.recent,
|
|
@@ -252,6 +277,14 @@ function profileData(config, options) {
|
|
|
252
277
|
return name === 'default' ? { ...legacyProfile(config), ...fromProfiles } : fromProfiles;
|
|
253
278
|
}
|
|
254
279
|
|
|
280
|
+
function profileIsAuthoritative(config, options) {
|
|
281
|
+
return Boolean(
|
|
282
|
+
options.profile
|
|
283
|
+
|| process.env.CLIPIT_PROFILE
|
|
284
|
+
|| (config.currentProfile && config.currentProfile !== 'default'),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
255
288
|
function updateProfile(config, options, updates) {
|
|
256
289
|
const name = profileName(config, options);
|
|
257
290
|
const profiles = { ...(config.profiles || {}) };
|
|
@@ -421,6 +454,7 @@ function usage() {
|
|
|
421
454
|
' clipit auth status [--json]',
|
|
422
455
|
' clipit auth set-key --stdin',
|
|
423
456
|
' clipit auth profiles',
|
|
457
|
+
' clipit auth use <profile>',
|
|
424
458
|
' clipit context use [--video-id id] [--clip-id id] [--project-id id] [--sequence-id id]',
|
|
425
459
|
' clipit context show|clear|build [--json]',
|
|
426
460
|
' clipit skills list [--json]',
|
|
@@ -431,8 +465,8 @@ function usage() {
|
|
|
431
465
|
' clipit workflow approve <jobId> --approval-id id [--decision approved|cheaper|cancelled]',
|
|
432
466
|
' clipit mcp [stdio]',
|
|
433
467
|
' clipit run <functionName> [--params @file.json] [--clip-id id] [--video-id id] [--confirm] [--max-credits n]',
|
|
434
|
-
' clipit videos list|get|upload|import-url|transcribe|transcript|suggest-clips|delete ...',
|
|
435
|
-
' clipit clips list|get|create|update|render|download|delete ...',
|
|
468
|
+
' clipit videos list|get|upload|abort-upload|import-url|transcribe|transcript|suggest-clips|delete ...',
|
|
469
|
+
' clipit clips list|get|delivery-state|create|update|render|download|delete ...',
|
|
436
470
|
' clipit jobs get|wait <jobId>',
|
|
437
471
|
' clipit credits balance|usage|estimate ...',
|
|
438
472
|
' clipit billing capabilities|catalog|create-attempt|attempt|receipt|subscription ...',
|
|
@@ -451,12 +485,36 @@ function usage() {
|
|
|
451
485
|
|
|
452
486
|
function getBaseUrl(config, options) {
|
|
453
487
|
const profile = profileData(config, options);
|
|
454
|
-
|
|
488
|
+
const selected = profileIsAuthoritative(config, options);
|
|
489
|
+
return String(
|
|
490
|
+
options['base-url']
|
|
491
|
+
|| (selected ? profile.baseUrl : process.env.CLIPPER_BASE_URL)
|
|
492
|
+
|| profile.baseUrl
|
|
493
|
+
|| (!selected ? process.env.CLIPPER_BASE_URL : undefined)
|
|
494
|
+
|| DEFAULT_BASE_URL,
|
|
495
|
+
).replace(/\/+$/, '');
|
|
455
496
|
}
|
|
456
497
|
|
|
457
|
-
function
|
|
498
|
+
function resolveApiCredential(config, options) {
|
|
458
499
|
const profile = profileData(config, options);
|
|
459
|
-
|
|
500
|
+
if (typeof options['api-key'] === 'string' && options['api-key'].trim()) {
|
|
501
|
+
return { apiKey: options['api-key'].trim(), source: 'argument' };
|
|
502
|
+
}
|
|
503
|
+
if (profileIsAuthoritative(config, options)) {
|
|
504
|
+
return profile.apiKey
|
|
505
|
+
? { apiKey: profile.apiKey, source: 'profile' }
|
|
506
|
+
: { apiKey: null, source: 'missing' };
|
|
507
|
+
}
|
|
508
|
+
if (process.env.CLIPPER_API_KEY) {
|
|
509
|
+
return { apiKey: process.env.CLIPPER_API_KEY, source: 'environment' };
|
|
510
|
+
}
|
|
511
|
+
return profile.apiKey
|
|
512
|
+
? { apiKey: profile.apiKey, source: 'profile' }
|
|
513
|
+
: { apiKey: null, source: 'missing' };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function getApiKey(config, options) {
|
|
517
|
+
return resolveApiCredential(config, options).apiKey;
|
|
460
518
|
}
|
|
461
519
|
|
|
462
520
|
async function readStdin() {
|
|
@@ -465,6 +523,97 @@ async function readStdin() {
|
|
|
465
523
|
return Buffer.concat(chunks).toString('utf8');
|
|
466
524
|
}
|
|
467
525
|
|
|
526
|
+
async function readSecretLine(input = process.stdin, promptOutput = process.stderr) {
|
|
527
|
+
if (!input.isTTY || typeof input.setRawMode !== 'function') {
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
let value = '';
|
|
530
|
+
let settled = false;
|
|
531
|
+
const cleanup = () => {
|
|
532
|
+
input.off('data', onData);
|
|
533
|
+
input.off('end', onEnd);
|
|
534
|
+
input.off('error', onError);
|
|
535
|
+
};
|
|
536
|
+
const finish = (result) => {
|
|
537
|
+
if (settled) return;
|
|
538
|
+
settled = true;
|
|
539
|
+
cleanup();
|
|
540
|
+
input.pause?.();
|
|
541
|
+
resolve(result);
|
|
542
|
+
};
|
|
543
|
+
const onData = (chunk) => {
|
|
544
|
+
value += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
|
|
545
|
+
const newline = value.search(/[\r\n]/);
|
|
546
|
+
if (newline !== -1) finish(value.slice(0, newline));
|
|
547
|
+
};
|
|
548
|
+
const onEnd = () => finish(value);
|
|
549
|
+
const onError = (error) => {
|
|
550
|
+
if (settled) return;
|
|
551
|
+
settled = true;
|
|
552
|
+
cleanup();
|
|
553
|
+
reject(error);
|
|
554
|
+
};
|
|
555
|
+
input.on('data', onData);
|
|
556
|
+
input.once('end', onEnd);
|
|
557
|
+
input.once('error', onError);
|
|
558
|
+
input.resume?.();
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return new Promise((resolve, reject) => {
|
|
563
|
+
let value = '';
|
|
564
|
+
let settled = false;
|
|
565
|
+
const wasRaw = Boolean(input.isRaw);
|
|
566
|
+
const wasPaused = input.isPaused?.() ?? false;
|
|
567
|
+
const cleanup = () => {
|
|
568
|
+
input.off('data', onData);
|
|
569
|
+
input.off('end', onEnd);
|
|
570
|
+
input.off('error', onError);
|
|
571
|
+
input.setRawMode(wasRaw);
|
|
572
|
+
if (wasPaused) input.pause?.();
|
|
573
|
+
promptOutput.write('\n');
|
|
574
|
+
};
|
|
575
|
+
const finish = () => {
|
|
576
|
+
if (settled) return;
|
|
577
|
+
settled = true;
|
|
578
|
+
cleanup();
|
|
579
|
+
resolve(value);
|
|
580
|
+
};
|
|
581
|
+
const onData = (chunk) => {
|
|
582
|
+
for (const character of (Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk))) {
|
|
583
|
+
if (character === '\u0003') {
|
|
584
|
+
if (settled) return;
|
|
585
|
+
settled = true;
|
|
586
|
+
cleanup();
|
|
587
|
+
reject(Object.assign(new Error('API key entry cancelled.'), { exitCode: 130 }));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (character === '\r' || character === '\n' || character === '\u0004') {
|
|
591
|
+
finish();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (character === '\u007f' || character === '\b') {
|
|
595
|
+
value = value.slice(0, -1);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (character >= ' ') value += character;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
const onEnd = () => finish();
|
|
602
|
+
const onError = (error) => {
|
|
603
|
+
if (settled) return;
|
|
604
|
+
settled = true;
|
|
605
|
+
cleanup();
|
|
606
|
+
reject(error);
|
|
607
|
+
};
|
|
608
|
+
promptOutput.write('API key: ');
|
|
609
|
+
input.setRawMode(true);
|
|
610
|
+
input.on('data', onData);
|
|
611
|
+
input.once('end', onEnd);
|
|
612
|
+
input.once('error', onError);
|
|
613
|
+
input.resume?.();
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
468
617
|
function boolOption(value) {
|
|
469
618
|
return value === true || value === 'true' || value === '1' || value === 'yes';
|
|
470
619
|
}
|
|
@@ -581,39 +730,67 @@ async function apiFetch(config, options, method, endpoint, body, extra = {}) {
|
|
|
581
730
|
};
|
|
582
731
|
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
583
732
|
if (body !== undefined && !isFormData && !extra.rawBody) headers['Content-Type'] = 'application/json';
|
|
584
|
-
const apiKey = extra.noAuth
|
|
733
|
+
const apiKey = extra.noAuth
|
|
734
|
+
? null
|
|
735
|
+
: extra.authApiKey !== undefined
|
|
736
|
+
? extra.authApiKey
|
|
737
|
+
: getApiKey(config, options);
|
|
585
738
|
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
586
739
|
|
|
587
740
|
let response;
|
|
741
|
+
let responseTimeout;
|
|
588
742
|
for (let attempt = 0; attempt <= GET_RETRY_DELAYS_MS.length; attempt++) {
|
|
743
|
+
const controller = new AbortController();
|
|
744
|
+
const timeoutMs = Math.min(
|
|
745
|
+
readPositiveIntegerEnv('CLIPIT_CLI_REQUEST_TIMEOUT_MS', API_REQUEST_TIMEOUT_MS, 5 * 60_000),
|
|
746
|
+
5 * 60_000,
|
|
747
|
+
);
|
|
748
|
+
const timeout = setTimeout(() => {
|
|
749
|
+
controller.abort(new Error(`ClipIt API request timed out after ${timeoutMs} ms`));
|
|
750
|
+
}, timeoutMs);
|
|
751
|
+
timeout.unref?.();
|
|
589
752
|
try {
|
|
590
753
|
const request = {
|
|
591
754
|
method: methodName,
|
|
592
755
|
headers,
|
|
593
756
|
body: body === undefined ? undefined : extra.rawBody ? body : isFormData ? body : JSON.stringify(body),
|
|
757
|
+
signal: controller.signal,
|
|
594
758
|
};
|
|
595
759
|
if (extra.rawBody && body !== undefined) {
|
|
596
760
|
request.duplex = 'half';
|
|
597
761
|
}
|
|
598
762
|
response = await fetch(`${baseUrl}${endpoint}`, request);
|
|
599
763
|
} catch (error) {
|
|
764
|
+
clearTimeout(timeout);
|
|
600
765
|
if (canRetry && attempt < GET_RETRY_DELAYS_MS.length) {
|
|
601
766
|
await sleep(GET_RETRY_DELAYS_MS[attempt]);
|
|
602
767
|
continue;
|
|
603
768
|
}
|
|
604
|
-
|
|
769
|
+
const reason = controller.signal.aborted && controller.signal.reason instanceof Error
|
|
770
|
+
? controller.signal.reason
|
|
771
|
+
: error;
|
|
772
|
+
throw Object.assign(new Error(`Network error: ${reason.message}`), { exitCode: EXIT.NETWORK });
|
|
605
773
|
}
|
|
606
774
|
|
|
607
775
|
if (canRetry && RETRYABLE_GET_STATUSES.has(response.status) && attempt < GET_RETRY_DELAYS_MS.length) {
|
|
608
776
|
await response.arrayBuffer().catch(() => undefined);
|
|
777
|
+
clearTimeout(timeout);
|
|
609
778
|
await sleep(retryDelayMs(response, attempt));
|
|
610
779
|
continue;
|
|
611
780
|
}
|
|
781
|
+
responseTimeout = timeout;
|
|
612
782
|
break;
|
|
613
783
|
}
|
|
614
784
|
|
|
615
785
|
const requestId = response.headers.get('x-request-id') || response.headers.get('X-Request-Id') || undefined;
|
|
616
|
-
|
|
786
|
+
let text;
|
|
787
|
+
try {
|
|
788
|
+
text = await response.text();
|
|
789
|
+
} catch (error) {
|
|
790
|
+
throw Object.assign(new Error(`Network error: ${error.message}`), { exitCode: EXIT.NETWORK });
|
|
791
|
+
} finally {
|
|
792
|
+
if (responseTimeout) clearTimeout(responseTimeout);
|
|
793
|
+
}
|
|
617
794
|
let data = null;
|
|
618
795
|
if (text.trim()) {
|
|
619
796
|
try {
|
|
@@ -638,6 +815,13 @@ async function apiFetch(config, options, method, endpoint, body, extra = {}) {
|
|
|
638
815
|
requestId,
|
|
639
816
|
});
|
|
640
817
|
}
|
|
818
|
+
if (extra.includeResponseMetadata) {
|
|
819
|
+
return {
|
|
820
|
+
data,
|
|
821
|
+
status: response.status,
|
|
822
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
823
|
+
};
|
|
824
|
+
}
|
|
641
825
|
return data;
|
|
642
826
|
}
|
|
643
827
|
|
|
@@ -813,15 +997,6 @@ async function fetchClipForEstimate(config, options, clipId) {
|
|
|
813
997
|
return apiFetch(config, options, 'GET', `/api/v1/clips/${encodeURIComponent(clipId)}`);
|
|
814
998
|
}
|
|
815
999
|
|
|
816
|
-
function progressTransform(progress) {
|
|
817
|
-
return new Transform({
|
|
818
|
-
transform(chunk, encoding, callback) {
|
|
819
|
-
progress.track(chunk);
|
|
820
|
-
callback(null, chunk);
|
|
821
|
-
},
|
|
822
|
-
});
|
|
823
|
-
}
|
|
824
|
-
|
|
825
1000
|
async function buildMaxCreditsEstimateRequest(config, options, spec, context = {}) {
|
|
826
1001
|
if (spec.metrics === 'url-import') {
|
|
827
1002
|
const youtube = isYoutubeUrl(requiredString(context.url, 'URL'));
|
|
@@ -1294,7 +1469,7 @@ async function setKey(config, options) {
|
|
|
1294
1469
|
if (!options.stdin) {
|
|
1295
1470
|
throw Object.assign(new Error('Use --stdin to avoid shell history leaks.'), { exitCode: EXIT.USAGE });
|
|
1296
1471
|
}
|
|
1297
|
-
const apiKey = (await
|
|
1472
|
+
const apiKey = (await readSecretLine()).trim();
|
|
1298
1473
|
if (!apiKey) {
|
|
1299
1474
|
throw Object.assign(new Error('No API key received on stdin.'), { exitCode: EXIT.USAGE });
|
|
1300
1475
|
}
|
|
@@ -1303,17 +1478,50 @@ async function setKey(config, options) {
|
|
|
1303
1478
|
apiKey,
|
|
1304
1479
|
loginSource: 'manual',
|
|
1305
1480
|
});
|
|
1306
|
-
const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me');
|
|
1307
|
-
await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey }));
|
|
1308
|
-
output({
|
|
1481
|
+
const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me', undefined, { authApiKey: apiKey });
|
|
1482
|
+
await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey, scope: me.scope ?? null }));
|
|
1483
|
+
output({
|
|
1484
|
+
success: true,
|
|
1485
|
+
message: 'API key stored',
|
|
1486
|
+
profile: profileName(config, options),
|
|
1487
|
+
account: me.user,
|
|
1488
|
+
apiKey: me.apiKey,
|
|
1489
|
+
scope: me.scope ?? null,
|
|
1490
|
+
}, options);
|
|
1309
1491
|
}
|
|
1310
1492
|
|
|
1311
1493
|
async function logout(config, options) {
|
|
1312
|
-
const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'loginSource']);
|
|
1494
|
+
const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'scope', 'loginSource']);
|
|
1313
1495
|
await writeConfig(next);
|
|
1314
1496
|
output({ success: true, message: 'Local ClipIt CLI credentials removed', profile: profileName(config, options) }, options);
|
|
1315
1497
|
}
|
|
1316
1498
|
|
|
1499
|
+
async function useProfile(config, options, requestedName) {
|
|
1500
|
+
const name = String(requestedName || '').trim();
|
|
1501
|
+
if (!name) {
|
|
1502
|
+
throw Object.assign(new Error('Profile name is required.'), { exitCode: EXIT.USAGE });
|
|
1503
|
+
}
|
|
1504
|
+
if (process.env.CLIPIT_PROFILE && process.env.CLIPIT_PROFILE !== name) {
|
|
1505
|
+
throw Object.assign(
|
|
1506
|
+
new Error(`CLIPIT_PROFILE is set to ${process.env.CLIPIT_PROFILE}; unset it or select that profile explicitly.`),
|
|
1507
|
+
{ exitCode: EXIT.USAGE },
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
const selectedOptions = { ...options, profile: name };
|
|
1511
|
+
const profile = profileData(config, selectedOptions);
|
|
1512
|
+
if (!profile.apiKey) {
|
|
1513
|
+
throw Object.assign(new Error(`Profile ${name} does not have a stored credential.`), { exitCode: EXIT.AUTH });
|
|
1514
|
+
}
|
|
1515
|
+
const next = { ...config, currentProfile: name, updatedAt: new Date().toISOString() };
|
|
1516
|
+
await writeConfig(next);
|
|
1517
|
+
output({
|
|
1518
|
+
success: true,
|
|
1519
|
+
currentProfile: name,
|
|
1520
|
+
keyName: profile.keyInfo?.keyName ?? null,
|
|
1521
|
+
scope: profile.scope ?? null,
|
|
1522
|
+
}, options);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1317
1525
|
async function listProfiles(config, options) {
|
|
1318
1526
|
const profiles = config.profiles || {};
|
|
1319
1527
|
const names = [...new Set(['default', ...Object.keys(profiles)])];
|
|
@@ -1329,6 +1537,7 @@ async function listProfiles(config, options) {
|
|
|
1329
1537
|
hasCredential: Boolean(data.apiKey),
|
|
1330
1538
|
loginSource: data.loginSource || null,
|
|
1331
1539
|
keyName: data.keyInfo?.keyName || null,
|
|
1540
|
+
scope: data.scope || null,
|
|
1332
1541
|
updatedAt: data.updatedAt || null,
|
|
1333
1542
|
};
|
|
1334
1543
|
}),
|
|
@@ -1336,6 +1545,7 @@ async function listProfiles(config, options) {
|
|
|
1336
1545
|
}
|
|
1337
1546
|
|
|
1338
1547
|
async function doctor(config, options) {
|
|
1548
|
+
const credential = resolveApiCredential(config, options);
|
|
1339
1549
|
const checks = {
|
|
1340
1550
|
version: VERSION,
|
|
1341
1551
|
node: process.version,
|
|
@@ -1343,8 +1553,8 @@ async function doctor(config, options) {
|
|
|
1343
1553
|
configPath: configPath(),
|
|
1344
1554
|
profile: profileName(config, options),
|
|
1345
1555
|
baseUrl: getBaseUrl(config, options),
|
|
1346
|
-
hasCredential: Boolean(
|
|
1347
|
-
credentialSource:
|
|
1556
|
+
hasCredential: Boolean(credential.apiKey),
|
|
1557
|
+
credentialSource: credential.source,
|
|
1348
1558
|
auth: null,
|
|
1349
1559
|
};
|
|
1350
1560
|
try {
|
|
@@ -1650,11 +1860,33 @@ async function pollWorkflow(config, options, jobId) {
|
|
|
1650
1860
|
}
|
|
1651
1861
|
}
|
|
1652
1862
|
|
|
1863
|
+
function promptNamesNewSourceUrl(userMessage) {
|
|
1864
|
+
const urls = userMessage.match(/https?:\/\/[^\s<>"'`]+/gi) || [];
|
|
1865
|
+
if (urls.length !== 1) return false;
|
|
1866
|
+
const messageWithoutUrls = userMessage
|
|
1867
|
+
.replace(/https?:\/\/[^\s<>"'`]+/gi, ' ')
|
|
1868
|
+
.replace(/[^a-z0-9]+/gi, ' ')
|
|
1869
|
+
.trim()
|
|
1870
|
+
.toLowerCase();
|
|
1871
|
+
const isUrlOnly = messageWithoutUrls.length === 0;
|
|
1872
|
+
const hasStrongSourceIntent = /\b(import|process|source|transcrib\w*|download|extract)\b/.test(messageWithoutUrls)
|
|
1873
|
+
|| /\b(clip|clips|video)\b.*\b(from|using|this|that)\b/.test(messageWithoutUrls)
|
|
1874
|
+
|| /\b(use|using)\b.*\b(link|url|source|video|this|that)\b/.test(messageWithoutUrls);
|
|
1875
|
+
const isReferenceOnly = /\b(reference|inspiration|example|style)\b/.test(messageWithoutUrls)
|
|
1876
|
+
&& !/\b(import|process|source|transcrib\w*|clip|clips|extract)\b/.test(messageWithoutUrls);
|
|
1877
|
+
return isUrlOnly || (hasStrongSourceIntent && !isReferenceOnly);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1653
1880
|
async function askWorkflow(config, options, promptParts) {
|
|
1654
1881
|
const userMessage = promptParts.filter((part) => part !== undefined).join(' ').trim();
|
|
1655
1882
|
if (!userMessage) throw Object.assign(new Error('Prompt is required.'), { exitCode: EXIT.USAGE });
|
|
1656
1883
|
|
|
1657
1884
|
const context = await buildContext(config, options);
|
|
1885
|
+
if (promptNamesNewSourceUrl(userMessage)) {
|
|
1886
|
+
if (!options['video-id']) delete context.videoId;
|
|
1887
|
+
if (!options['clip-id']) delete context.clipId;
|
|
1888
|
+
if (!options['selected-clip-ids']) delete context.selectedClipIds;
|
|
1889
|
+
}
|
|
1658
1890
|
const payload = { userMessage };
|
|
1659
1891
|
for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
|
|
1660
1892
|
if (context[field]) payload[field] = context[field];
|
|
@@ -1733,16 +1965,19 @@ function mimeForPath(filePath) {
|
|
|
1733
1965
|
if (ext === '.webm') return 'video/webm';
|
|
1734
1966
|
if (ext === '.mkv') return 'video/x-matroska';
|
|
1735
1967
|
if (ext === '.m4v') return 'video/x-m4v';
|
|
1968
|
+
if (ext === '.avi') return 'video/x-msvideo';
|
|
1969
|
+
if (ext === '.wmv' || ext === '.asf') return 'video/x-ms-asf';
|
|
1970
|
+
if (ext === '.flv') return 'video/x-flv';
|
|
1971
|
+
if (ext === '.mpeg' || ext === '.mpg') return 'video/mpeg';
|
|
1972
|
+
if (ext === '.ts' || ext === '.m2ts') return 'video/mp2t';
|
|
1973
|
+
if (ext === '.mxf') return 'video/mxf';
|
|
1974
|
+
if (ext === '.ogv') return 'video/ogg';
|
|
1736
1975
|
if (ext === '.mp3') return 'audio/mpeg';
|
|
1737
1976
|
if (ext === '.m4a') return 'audio/mp4';
|
|
1738
1977
|
if (ext === '.wav') return 'audio/wav';
|
|
1739
1978
|
return 'video/mp4';
|
|
1740
1979
|
}
|
|
1741
1980
|
|
|
1742
|
-
function multipartFilename(value) {
|
|
1743
|
-
return String(value).replace(/["\r\n]/g, '_');
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
1981
|
function shouldReportUploadProgress(options) {
|
|
1747
1982
|
return Boolean(process.stderr.isTTY) && !wantJson(options);
|
|
1748
1983
|
}
|
|
@@ -1751,24 +1986,6 @@ function formatMb(bytes) {
|
|
|
1751
1986
|
return (bytes / (1024 * 1024)).toFixed(1);
|
|
1752
1987
|
}
|
|
1753
1988
|
|
|
1754
|
-
function assertDirectVideoUploadSize(filePath, sizeBytes) {
|
|
1755
|
-
if (sizeBytes <= DIRECT_VIDEO_UPLOAD_MAX_BYTES) return;
|
|
1756
|
-
throw Object.assign(
|
|
1757
|
-
new Error(
|
|
1758
|
-
`Video file is ${formatMb(sizeBytes)} MB, above the direct CLI upload limit of ${formatMb(DIRECT_VIDEO_UPLOAD_MAX_BYTES)} MB. ` +
|
|
1759
|
-
'Use `clipit videos import-url` for URL sources or a server-side/resumable upload path; direct CLI upload is likely to fail with 413 before ClipIt can process it.',
|
|
1760
|
-
),
|
|
1761
|
-
{
|
|
1762
|
-
exitCode: EXIT.USAGE,
|
|
1763
|
-
data: {
|
|
1764
|
-
filePath,
|
|
1765
|
-
sizeBytes,
|
|
1766
|
-
maxBytes: DIRECT_VIDEO_UPLOAD_MAX_BYTES,
|
|
1767
|
-
},
|
|
1768
|
-
},
|
|
1769
|
-
);
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
1989
|
function createUploadProgress(options, totalBytes) {
|
|
1773
1990
|
if (!shouldReportUploadProgress(options) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
|
|
1774
1991
|
return { track() {}, finish() {} };
|
|
@@ -1797,15 +2014,259 @@ function createUploadProgress(options, totalBytes) {
|
|
|
1797
2014
|
};
|
|
1798
2015
|
}
|
|
1799
2016
|
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
2017
|
+
const VIDEO_UPLOAD_RESUME_MAX_AGE_MS = 25 * 60 * 60 * 1000;
|
|
2018
|
+
|
|
2019
|
+
function videoUploadResumeDirectory() {
|
|
2020
|
+
return path.join(configDir(), 'video-upload-resumes');
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
function videoUploadResumePath(idempotencyKey) {
|
|
2024
|
+
const digest = createHash('sha256').update(String(idempotencyKey)).digest('hex');
|
|
2025
|
+
return path.join(videoUploadResumeDirectory(), `${digest}.json`);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async function readVideoUploadResumes() {
|
|
2029
|
+
const resumes = {};
|
|
2030
|
+
await fs.mkdir(videoUploadResumeDirectory(), { recursive: true, mode: 0o700 });
|
|
2031
|
+
const files = await fs.readdir(videoUploadResumeDirectory()).catch(() => []);
|
|
2032
|
+
for (const file of files) {
|
|
2033
|
+
if (!file.endsWith('.json')) continue;
|
|
2034
|
+
const recordPath = path.join(videoUploadResumeDirectory(), file);
|
|
2035
|
+
try {
|
|
2036
|
+
const value = JSON.parse(await fs.readFile(recordPath, 'utf8'));
|
|
2037
|
+
const updatedAt = Date.parse(value?.updatedAt);
|
|
2038
|
+
if (
|
|
2039
|
+
!value
|
|
2040
|
+
|| typeof value !== 'object'
|
|
2041
|
+
|| Array.isArray(value)
|
|
2042
|
+
|| typeof value.idempotencyKey !== 'string'
|
|
2043
|
+
|| !Number.isFinite(updatedAt)
|
|
2044
|
+
|| Date.now() - updatedAt > VIDEO_UPLOAD_RESUME_MAX_AGE_MS
|
|
2045
|
+
) {
|
|
2046
|
+
await fs.rm(recordPath, { force: true });
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
resumes[value.idempotencyKey] = value;
|
|
2050
|
+
} catch {
|
|
2051
|
+
await fs.rm(recordPath, { force: true }).catch(() => undefined);
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const legacyPath = path.join(configDir(), 'video-upload-resume.json');
|
|
2056
|
+
try {
|
|
2057
|
+
const legacy = JSON.parse(await fs.readFile(legacyPath, 'utf8'));
|
|
2058
|
+
if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
|
|
2059
|
+
for (const [idempotencyKey, value] of Object.entries(legacy)) {
|
|
2060
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
2061
|
+
await saveVideoUploadResume(idempotencyKey, value);
|
|
2062
|
+
resumes[idempotencyKey] = { ...value, idempotencyKey };
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
await fs.rm(legacyPath, { force: true });
|
|
2066
|
+
} catch {}
|
|
2067
|
+
|
|
2068
|
+
return resumes;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
async function saveVideoUploadResume(idempotencyKey, value) {
|
|
2072
|
+
await fs.mkdir(videoUploadResumeDirectory(), { recursive: true, mode: 0o700 });
|
|
2073
|
+
const destination = videoUploadResumePath(idempotencyKey);
|
|
2074
|
+
const temporary = `${destination}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
2075
|
+
await fs.writeFile(
|
|
2076
|
+
temporary,
|
|
2077
|
+
`${JSON.stringify({ ...value, idempotencyKey }, null, 2)}\n`,
|
|
2078
|
+
{ mode: 0o600 },
|
|
2079
|
+
);
|
|
2080
|
+
await fs.rename(temporary, destination);
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
async function removeVideoUploadResume(idempotencyKey) {
|
|
2084
|
+
await fs.rm(videoUploadResumePath(idempotencyKey), { force: true });
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
async function removeVideoUploadResumeByIntent(intentId) {
|
|
2088
|
+
const resumes = await readVideoUploadResumes();
|
|
2089
|
+
await Promise.all(Object.entries(resumes).map(async ([key, value]) => {
|
|
2090
|
+
if (value?.intentId === intentId) await removeVideoUploadResume(key);
|
|
2091
|
+
}));
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
function defaultVideoUploadIdempotencyKey(resolved, stat, filename, contentType) {
|
|
2095
|
+
const fingerprint = createHash('sha256').update(JSON.stringify({
|
|
2096
|
+
path: resolved,
|
|
2097
|
+
size: stat.size,
|
|
2098
|
+
mtimeMs: stat.mtimeMs,
|
|
2099
|
+
ctimeMs: stat.ctimeMs,
|
|
2100
|
+
filename,
|
|
2101
|
+
contentType,
|
|
2102
|
+
})).digest('hex');
|
|
2103
|
+
return `cli-video:${fingerprint}`;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function createMultipartUploadProgress(options, totalBytes, completedParts) {
|
|
2107
|
+
if (!shouldReportUploadProgress(options) || totalBytes <= 0) {
|
|
2108
|
+
return { updatePart() {}, completePart() {}, resetPart() {}, finish() {} };
|
|
2109
|
+
}
|
|
2110
|
+
const completed = new Map(completedParts.map((part) => [part.partNumber, part.sizeBytes]));
|
|
2111
|
+
const inFlight = new Map();
|
|
2112
|
+
let lastPrintedAt = 0;
|
|
2113
|
+
|
|
2114
|
+
const print = (force = false) => {
|
|
2115
|
+
const now = Date.now();
|
|
2116
|
+
if (!force && now - lastPrintedAt < 500) return;
|
|
2117
|
+
lastPrintedAt = now;
|
|
2118
|
+
const uploaded = Math.min(
|
|
2119
|
+
totalBytes,
|
|
2120
|
+
[...completed.values(), ...inFlight.values()].reduce((sum, value) => sum + value, 0),
|
|
2121
|
+
);
|
|
2122
|
+
const percent = Math.min(100, Math.round((uploaded / totalBytes) * 100));
|
|
2123
|
+
process.stderr.write(`\ruploaded ${formatMb(uploaded)} / ${formatMb(totalBytes)} MB (${percent}%)`);
|
|
2124
|
+
};
|
|
2125
|
+
|
|
2126
|
+
return {
|
|
2127
|
+
updatePart(partNumber, bytes) {
|
|
2128
|
+
inFlight.set(partNumber, bytes);
|
|
2129
|
+
print(false);
|
|
2130
|
+
},
|
|
2131
|
+
completePart(partNumber, bytes) {
|
|
2132
|
+
inFlight.delete(partNumber);
|
|
2133
|
+
completed.set(partNumber, bytes);
|
|
2134
|
+
print(false);
|
|
2135
|
+
},
|
|
2136
|
+
resetPart(partNumber) {
|
|
2137
|
+
inFlight.delete(partNumber);
|
|
2138
|
+
print(false);
|
|
2139
|
+
},
|
|
2140
|
+
finish() {
|
|
2141
|
+
print(true);
|
|
2142
|
+
process.stderr.write('\n');
|
|
2143
|
+
},
|
|
2144
|
+
};
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function createUploadActivityWatchdog(controller, onTimeout) {
|
|
2148
|
+
let timer;
|
|
2149
|
+
const arm = () => {
|
|
2150
|
+
if (timer) clearTimeout(timer);
|
|
2151
|
+
timer = setTimeout(() => {
|
|
2152
|
+
const error = Object.assign(
|
|
2153
|
+
new Error(`Storage upload made no progress for ${VIDEO_UPLOAD_STALL_TIMEOUT_MS} ms`),
|
|
2154
|
+
{ code: 'UPLOAD_STALLED' },
|
|
2155
|
+
);
|
|
2156
|
+
controller.abort(error);
|
|
2157
|
+
onTimeout?.();
|
|
2158
|
+
}, VIDEO_UPLOAD_STALL_TIMEOUT_MS);
|
|
2159
|
+
timer.unref?.();
|
|
2160
|
+
};
|
|
2161
|
+
arm();
|
|
2162
|
+
return {
|
|
2163
|
+
activity: arm,
|
|
2164
|
+
stop() {
|
|
2165
|
+
if (timer) clearTimeout(timer);
|
|
2166
|
+
timer = undefined;
|
|
2167
|
+
},
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
async function putSignedVideoPart(part, filePath, start, size, progress) {
|
|
2172
|
+
for (let attempt = 0; attempt < VIDEO_UPLOAD_PART_ATTEMPTS; attempt += 1) {
|
|
2173
|
+
let transferred = 0;
|
|
2174
|
+
const controller = new AbortController();
|
|
2175
|
+
let source;
|
|
2176
|
+
let tracker;
|
|
2177
|
+
const watchdog = createUploadActivityWatchdog(controller, () => {
|
|
2178
|
+
source?.destroy();
|
|
2179
|
+
tracker?.destroy();
|
|
2180
|
+
});
|
|
2181
|
+
tracker = new Transform({
|
|
2182
|
+
transform(chunk, encoding, callback) {
|
|
2183
|
+
transferred += Buffer.byteLength(chunk);
|
|
2184
|
+
watchdog.activity();
|
|
2185
|
+
progress.updatePart(part.partNumber, transferred);
|
|
2186
|
+
callback(null, chunk);
|
|
2187
|
+
},
|
|
2188
|
+
});
|
|
2189
|
+
try {
|
|
2190
|
+
source = createReadStream(filePath, { start, end: start + size - 1 });
|
|
2191
|
+
const response = await fetch(part.url, {
|
|
2192
|
+
method: 'PUT',
|
|
2193
|
+
headers: { 'Content-Length': String(size) },
|
|
2194
|
+
body: source.pipe(tracker),
|
|
2195
|
+
duplex: 'half',
|
|
2196
|
+
signal: controller.signal,
|
|
2197
|
+
});
|
|
2198
|
+
if (!response.ok) {
|
|
2199
|
+
const text = await response.text().catch(() => '');
|
|
2200
|
+
throw new Error(`Part ${part.partNumber} failed: ${response.status} ${redact(text || response.statusText)}`);
|
|
2201
|
+
}
|
|
2202
|
+
progress.completePart(part.partNumber, size);
|
|
2203
|
+
return;
|
|
2204
|
+
} catch (error) {
|
|
2205
|
+
progress.resetPart(part.partNumber);
|
|
2206
|
+
const reason = controller.signal.aborted && controller.signal.reason instanceof Error
|
|
2207
|
+
? controller.signal.reason
|
|
2208
|
+
: error;
|
|
2209
|
+
if (attempt === VIDEO_UPLOAD_PART_ATTEMPTS - 1) throw reason;
|
|
2210
|
+
await sleep(VIDEO_UPLOAD_RETRY_BASE_MS * (2 ** attempt));
|
|
2211
|
+
} finally {
|
|
2212
|
+
watchdog.stop();
|
|
2213
|
+
source?.destroy();
|
|
2214
|
+
tracker?.destroy();
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
async function uploadVideoMultipart(config, options, resolved, initialized, totalBytes) {
|
|
2220
|
+
const status = await apiFetch(
|
|
2221
|
+
config,
|
|
2222
|
+
options,
|
|
2223
|
+
'GET',
|
|
2224
|
+
`/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}`,
|
|
1803
2225
|
);
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2226
|
+
const uploadedParts = Array.isArray(status.uploadedParts) ? status.uploadedParts : [];
|
|
2227
|
+
const uploadedNumbers = new Set(uploadedParts.map((part) => Number(part.partNumber)));
|
|
2228
|
+
const missing = Array.from({ length: Number(initialized.partCount) }, (_, index) => index + 1)
|
|
2229
|
+
.filter((partNumber) => !uploadedNumbers.has(partNumber));
|
|
2230
|
+
const progress = createMultipartUploadProgress(options, totalBytes, uploadedParts);
|
|
2231
|
+
|
|
2232
|
+
try {
|
|
2233
|
+
for (let offset = 0; offset < missing.length; offset += VIDEO_UPLOAD_PART_SIGN_BATCH) {
|
|
2234
|
+
const partNumbers = missing.slice(offset, offset + VIDEO_UPLOAD_PART_SIGN_BATCH);
|
|
2235
|
+
const signed = await apiFetch(
|
|
2236
|
+
config,
|
|
2237
|
+
options,
|
|
2238
|
+
'POST',
|
|
2239
|
+
`/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}/parts`,
|
|
2240
|
+
{ partNumbers },
|
|
2241
|
+
);
|
|
2242
|
+
const queue = [...(signed.signedPartUrls || [])];
|
|
2243
|
+
const signedNumbers = new Set(queue.map((part) => Number(part?.partNumber)));
|
|
2244
|
+
if (
|
|
2245
|
+
queue.length !== partNumbers.length
|
|
2246
|
+
|| signedNumbers.size !== partNumbers.length
|
|
2247
|
+
|| partNumbers.some((partNumber) => !signedNumbers.has(partNumber))
|
|
2248
|
+
|| queue.some((part) => typeof part?.url !== 'string' || !part.url)
|
|
2249
|
+
) {
|
|
2250
|
+
throw new Error('Server returned an incomplete multipart signing batch.');
|
|
2251
|
+
}
|
|
2252
|
+
let cursor = 0;
|
|
2253
|
+
const worker = async () => {
|
|
2254
|
+
while (cursor < queue.length) {
|
|
2255
|
+
const part = queue[cursor++];
|
|
2256
|
+
const start = (part.partNumber - 1) * initialized.partSizeBytes;
|
|
2257
|
+
const size = Math.min(initialized.partSizeBytes, totalBytes - start);
|
|
2258
|
+
if (size !== Number(part.expectedSizeBytes)) {
|
|
2259
|
+
throw new Error(`Server returned an invalid size for upload part ${part.partNumber}.`);
|
|
2260
|
+
}
|
|
2261
|
+
await putSignedVideoPart(part, resolved, start, size, progress);
|
|
2262
|
+
}
|
|
2263
|
+
};
|
|
2264
|
+
const workerCount = Math.min(VIDEO_UPLOAD_PART_CONCURRENCY, queue.length);
|
|
2265
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
2266
|
+
}
|
|
2267
|
+
} finally {
|
|
2268
|
+
progress.finish();
|
|
1807
2269
|
}
|
|
1808
|
-
yield Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
1809
2270
|
}
|
|
1810
2271
|
|
|
1811
2272
|
async function uploadVideo(config, options, filePath) {
|
|
@@ -1815,26 +2276,147 @@ async function uploadVideo(config, options, filePath) {
|
|
|
1815
2276
|
if (!stat.isFile()) {
|
|
1816
2277
|
throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
|
|
1817
2278
|
}
|
|
1818
|
-
|
|
1819
|
-
const filename = options.filename || path.basename(resolved);
|
|
2279
|
+
const filename = String(options.filename || path.basename(resolved));
|
|
1820
2280
|
const contentType = mimeForPath(resolved);
|
|
1821
|
-
const
|
|
1822
|
-
|
|
1823
|
-
|
|
2281
|
+
const title = options.title === undefined || options.title === null
|
|
2282
|
+
? null
|
|
2283
|
+
: String(options.title);
|
|
2284
|
+
const idempotencyKey = String(
|
|
2285
|
+
options['idempotency-key']
|
|
2286
|
+
|| defaultVideoUploadIdempotencyKey(resolved, stat, filename, contentType),
|
|
1824
2287
|
);
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
2288
|
+
const uploadIdentity = {
|
|
2289
|
+
filePath: resolved,
|
|
2290
|
+
filename,
|
|
2291
|
+
title,
|
|
2292
|
+
profile: profileName(config, options),
|
|
2293
|
+
baseUrl: getBaseUrl(config, options),
|
|
2294
|
+
allowCustomHost: allowCustomHost(options),
|
|
2295
|
+
contentType,
|
|
2296
|
+
size: stat.size,
|
|
2297
|
+
mtimeMs: stat.mtimeMs,
|
|
2298
|
+
};
|
|
2299
|
+
const resumes = await readVideoUploadResumes();
|
|
2300
|
+
const existingResume = resumes[idempotencyKey];
|
|
2301
|
+
if (existingResume && typeof existingResume === 'object') {
|
|
2302
|
+
const mismatchedFields = Object.entries(uploadIdentity)
|
|
2303
|
+
.filter(([key, value]) => {
|
|
2304
|
+
const existingValue = existingResume[key];
|
|
2305
|
+
if (key === 'title') return (existingValue ?? null) !== value;
|
|
2306
|
+
return existingValue !== value;
|
|
2307
|
+
})
|
|
2308
|
+
.map(([key]) => key);
|
|
2309
|
+
if (mismatchedFields.length > 0) {
|
|
2310
|
+
const abortArgs = existingResume.intentId
|
|
2311
|
+
? ['videos', 'abort-upload', String(existingResume.intentId), '--profile', String(existingResume.profile || uploadIdentity.profile), '--base-url', String(existingResume.baseUrl || uploadIdentity.baseUrl), '--confirm']
|
|
2312
|
+
: null;
|
|
2313
|
+
if (abortArgs && existingResume.allowCustomHost === true) abortArgs.push('--allow-custom-host');
|
|
2314
|
+
throw Object.assign(
|
|
2315
|
+
new Error('The file or upload identity changed since this resumable upload began. Abort the prior upload and retry with a new idempotency key.'),
|
|
2316
|
+
{
|
|
2317
|
+
exitCode: EXIT.USAGE,
|
|
2318
|
+
data: {
|
|
2319
|
+
idempotencyKey,
|
|
2320
|
+
uploadIntentId: existingResume.intentId,
|
|
2321
|
+
mismatchedFields,
|
|
2322
|
+
abortArgs,
|
|
2323
|
+
abortCommand: abortArgs ? `clipit ${abortArgs.map(shellQuote).join(' ')}` : undefined,
|
|
2324
|
+
},
|
|
2325
|
+
},
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
1828
2329
|
await enforceMaxCredits(config, options, 'videos upload', { bytes: stat.size });
|
|
1829
2330
|
confirmPaid(options, 'Uploading a video');
|
|
2331
|
+
await saveVideoUploadResume(idempotencyKey, {
|
|
2332
|
+
...uploadIdentity,
|
|
2333
|
+
intentId: existingResume?.intentId,
|
|
2334
|
+
jobId: existingResume?.jobId,
|
|
2335
|
+
updatedAt: new Date().toISOString(),
|
|
2336
|
+
});
|
|
2337
|
+
|
|
2338
|
+
let initialized;
|
|
1830
2339
|
try {
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2340
|
+
initialized = await apiFetch(config, options, 'POST', '/api/v1/videos/uploads', {
|
|
2341
|
+
filename,
|
|
2342
|
+
contentType,
|
|
2343
|
+
size: stat.size,
|
|
2344
|
+
title: title ?? undefined,
|
|
2345
|
+
idempotencyKey,
|
|
1837
2346
|
});
|
|
2347
|
+
if (!initialized?.intentId || !initialized?.jobId) {
|
|
2348
|
+
throw Object.assign(new Error('Video upload initialization returned an unexpected response.'), {
|
|
2349
|
+
exitCode: EXIT.SERVER,
|
|
2350
|
+
data: initialized,
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
if (!initialized.readyForUpload) {
|
|
2354
|
+
await removeVideoUploadResume(idempotencyKey);
|
|
2355
|
+
if (initialized.videoId) {
|
|
2356
|
+
await persistActiveContext(
|
|
2357
|
+
config,
|
|
2358
|
+
options,
|
|
2359
|
+
{ videoId: initialized.videoId },
|
|
2360
|
+
[{ type: 'video', id: initialized.videoId }],
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
2363
|
+
output(initialized, options);
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
await saveVideoUploadResume(idempotencyKey, {
|
|
2368
|
+
...uploadIdentity,
|
|
2369
|
+
intentId: initialized.intentId,
|
|
2370
|
+
jobId: initialized.jobId,
|
|
2371
|
+
updatedAt: new Date().toISOString(),
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
if (initialized.transport === 'single') {
|
|
2375
|
+
if (!initialized.uploadUrl || Number(initialized.expectedSizeBytes) !== stat.size) {
|
|
2376
|
+
throw new Error('Direct upload initialization returned invalid transfer metadata.');
|
|
2377
|
+
}
|
|
2378
|
+
await putSignedUpload(
|
|
2379
|
+
initialized.uploadUrl,
|
|
2380
|
+
resolved,
|
|
2381
|
+
contentType,
|
|
2382
|
+
stat.size,
|
|
2383
|
+
options,
|
|
2384
|
+
initialized.requiredHeaders,
|
|
2385
|
+
);
|
|
2386
|
+
} else if (initialized.transport === 'multipart') {
|
|
2387
|
+
if (
|
|
2388
|
+
!Number.isInteger(initialized.partCount)
|
|
2389
|
+
|| !Number.isInteger(initialized.partSizeBytes)
|
|
2390
|
+
|| Number(initialized.expectedSizeBytes) !== stat.size
|
|
2391
|
+
) {
|
|
2392
|
+
throw new Error('Multipart upload initialization returned invalid transfer metadata.');
|
|
2393
|
+
}
|
|
2394
|
+
await uploadVideoMultipart(config, options, resolved, initialized, stat.size);
|
|
2395
|
+
} else {
|
|
2396
|
+
throw new Error('Video upload initialization returned no supported transport.');
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
const completedStat = await fs.stat(resolved);
|
|
2400
|
+
if (
|
|
2401
|
+
!completedStat.isFile()
|
|
2402
|
+
|| completedStat.size !== stat.size
|
|
2403
|
+
|| completedStat.mtimeMs !== stat.mtimeMs
|
|
2404
|
+
|| completedStat.ctimeMs !== stat.ctimeMs
|
|
2405
|
+
) {
|
|
2406
|
+
throw Object.assign(
|
|
2407
|
+
new Error('The video file changed while it was uploading. Abort this upload and retry with a new idempotency key.'),
|
|
2408
|
+
{ exitCode: EXIT.USAGE, data: { fileChangedDuringUpload: true } },
|
|
2409
|
+
);
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
const result = await apiFetch(
|
|
2413
|
+
config,
|
|
2414
|
+
options,
|
|
2415
|
+
'POST',
|
|
2416
|
+
`/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}/complete`,
|
|
2417
|
+
{},
|
|
2418
|
+
);
|
|
2419
|
+
await removeVideoUploadResume(idempotencyKey);
|
|
1838
2420
|
if (result?.videoId) {
|
|
1839
2421
|
await persistActiveContext(
|
|
1840
2422
|
config,
|
|
@@ -1844,8 +2426,42 @@ async function uploadVideo(config, options, filePath) {
|
|
|
1844
2426
|
);
|
|
1845
2427
|
}
|
|
1846
2428
|
output(result, options);
|
|
1847
|
-
}
|
|
1848
|
-
|
|
2429
|
+
} catch (error) {
|
|
2430
|
+
if (error && typeof error === 'object') {
|
|
2431
|
+
const requiresNewIdempotencyKey = error.data?.requiresNewIdempotencyKey === true
|
|
2432
|
+
|| error.data?.code === 'IDEMPOTENCY_KEY_TERMINAL';
|
|
2433
|
+
if (requiresNewIdempotencyKey) {
|
|
2434
|
+
await removeVideoUploadResume(idempotencyKey);
|
|
2435
|
+
error.data = {
|
|
2436
|
+
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
|
2437
|
+
uploadIntentId: initialized?.intentId ?? existingResume?.intentId,
|
|
2438
|
+
idempotencyKey,
|
|
2439
|
+
};
|
|
2440
|
+
throw error;
|
|
2441
|
+
}
|
|
2442
|
+
const resumeArgs = [
|
|
2443
|
+
'videos',
|
|
2444
|
+
'upload',
|
|
2445
|
+
resolved,
|
|
2446
|
+
'--idempotency-key',
|
|
2447
|
+
idempotencyKey,
|
|
2448
|
+
];
|
|
2449
|
+
resumeArgs.push('--filename', filename);
|
|
2450
|
+
if (title) resumeArgs.push('--title', title);
|
|
2451
|
+
resumeArgs.push('--profile', uploadIdentity.profile);
|
|
2452
|
+
resumeArgs.push('--base-url', uploadIdentity.baseUrl);
|
|
2453
|
+
if (uploadIdentity.allowCustomHost) resumeArgs.push('--allow-custom-host');
|
|
2454
|
+
if (boolOption(options.confirm) || boolOption(options.yes)) resumeArgs.push('--confirm');
|
|
2455
|
+
if (wantJson(options)) resumeArgs.push('--json');
|
|
2456
|
+
error.data = {
|
|
2457
|
+
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
|
2458
|
+
uploadIntentId: initialized?.intentId ?? existingResume?.intentId,
|
|
2459
|
+
idempotencyKey,
|
|
2460
|
+
resumeArgs,
|
|
2461
|
+
resumeCommand: `clipit ${resumeArgs.map(shellQuote).join(' ')}`,
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
throw error;
|
|
1849
2465
|
}
|
|
1850
2466
|
}
|
|
1851
2467
|
|
|
@@ -1866,13 +2482,30 @@ async function videos(config, options, action, args) {
|
|
|
1866
2482
|
if (!url) throw Object.assign(new Error('URL is required.'), { exitCode: EXIT.USAGE });
|
|
1867
2483
|
await enforceMaxCredits(config, options, 'videos import-url', { url });
|
|
1868
2484
|
confirmPaid(options, 'Importing a video from URL');
|
|
1869
|
-
output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', {
|
|
2485
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', {
|
|
2486
|
+
url,
|
|
2487
|
+
title: options.title,
|
|
2488
|
+
idempotencyKey: options['idempotency-key'] || randomUUID(),
|
|
2489
|
+
}), options);
|
|
1870
2490
|
return;
|
|
1871
2491
|
}
|
|
1872
2492
|
if (action === 'upload') {
|
|
1873
2493
|
await uploadVideo(config, options, args[0] || options.file);
|
|
1874
2494
|
return;
|
|
1875
2495
|
}
|
|
2496
|
+
if (action === 'abort-upload') {
|
|
2497
|
+
const intentId = requiredString(args[0] || options['intent-id'], 'Upload intent id');
|
|
2498
|
+
requireConfirm(options, 'Aborting a video upload');
|
|
2499
|
+
const result = await apiFetch(
|
|
2500
|
+
config,
|
|
2501
|
+
options,
|
|
2502
|
+
'DELETE',
|
|
2503
|
+
`/api/v1/videos/uploads/${encodeURIComponent(intentId)}`,
|
|
2504
|
+
);
|
|
2505
|
+
await removeVideoUploadResumeByIntent(intentId);
|
|
2506
|
+
output(result, options);
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
1876
2509
|
if (action === 'transcribe') {
|
|
1877
2510
|
if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
|
|
1878
2511
|
await enforceMaxCredits(config, options, 'videos transcribe', { videoId: args[0] });
|
|
@@ -1911,6 +2544,119 @@ async function videos(config, options, action, args) {
|
|
|
1911
2544
|
throw Object.assign(new Error(`Unknown videos command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1912
2545
|
}
|
|
1913
2546
|
|
|
2547
|
+
function formatClipDeliveryState(state) {
|
|
2548
|
+
const editor = state?.editorState;
|
|
2549
|
+
const selected = state?.selectedExport;
|
|
2550
|
+
const candidates = Array.isArray(state?.exports) ? state.exports : [];
|
|
2551
|
+
const blockers = Array.isArray(state?.deliveryBlockers) ? state.deliveryBlockers : [];
|
|
2552
|
+
const lines = [
|
|
2553
|
+
`Clip: ${state?.clipId || 'unknown'}`,
|
|
2554
|
+
editor
|
|
2555
|
+
? `Editor snapshot: ${editor.snapshotId}`
|
|
2556
|
+
: `Editor: ${state?.editorStateStatus || 'unavailable'}`,
|
|
2557
|
+
editor ? `Editor version: ${editor.editorVersion}` : null,
|
|
2558
|
+
editor ? `Editor state hash: ${editor.editorStateHash}` : null,
|
|
2559
|
+
editor ? `Source object fingerprint: ${editor.sourceObjectFingerprint || 'unavailable'}` : null,
|
|
2560
|
+
editor ? `Saved: ${editor.saveOrigin} at ${editor.savedAt}` : null,
|
|
2561
|
+
editor
|
|
2562
|
+
? `Edit: ${editor.aspectRatio}; captions ${editor.caption?.enabled ? 'on' : 'off'}${editor.caption?.presetId ? `/${editor.caption.presetId}` : ''}; crop ${editor.cropSegmentCount}; keyframes ${editor.keyframeCount}; layout ${editor.layout || 'none'}`
|
|
2563
|
+
: null,
|
|
2564
|
+
`Selection: ${state?.selection?.status || 'unknown'}${state?.selection?.selectedExportId ? ` (${state.selection.selectedExportId})` : ''}`,
|
|
2565
|
+
selected
|
|
2566
|
+
? `Selected export: ${selected.exportId}; snapshot ${selected.snapshotId || 'unavailable'}; exact current match ${selected.exactlyMatchesEditor === true ? 'yes' : 'no'}`
|
|
2567
|
+
: `Artifacts: ${candidates.length}`,
|
|
2568
|
+
selected ? `Output object fingerprint: ${selected.outputObjectFingerprint || 'unavailable'}` : null,
|
|
2569
|
+
selected ? `Storage item: ${selected.storageItemId || 'unavailable'}` : null,
|
|
2570
|
+
selected
|
|
2571
|
+
? `Artifact: ${selected.width || '?'}x${selected.height || '?'} ${selected.duration || '?'}s; audio ${selected.hasAudio === true ? 'yes' : selected.hasAudio === false ? 'no' : 'unknown'}; probe ${selected.inspectionStatus}`
|
|
2572
|
+
: null,
|
|
2573
|
+
`Ready to publish: ${state?.readyToPublish === true ? 'yes' : 'no'}`,
|
|
2574
|
+
].filter(Boolean);
|
|
2575
|
+
if (candidates.length > 1) {
|
|
2576
|
+
lines.push(`Export ids: ${candidates.map((candidate) => candidate.exportId).join(', ')}`);
|
|
2577
|
+
}
|
|
2578
|
+
if (blockers.length) {
|
|
2579
|
+
lines.push('Blockers:');
|
|
2580
|
+
lines.push(...blockers.map((blocker) => `- ${blocker}`));
|
|
2581
|
+
}
|
|
2582
|
+
return lines.join('\n');
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
function requireVerifiedEditorState(deliveryState, clipId) {
|
|
2586
|
+
const editor = deliveryState?.editorState;
|
|
2587
|
+
if (
|
|
2588
|
+
deliveryState?.schema !== 'clipit_clip_delivery_state'
|
|
2589
|
+
|| deliveryState?.version !== 2
|
|
2590
|
+
|| deliveryState?.clipId !== clipId
|
|
2591
|
+
|| deliveryState?.editorStateStatus !== 'verified'
|
|
2592
|
+
|| !editor
|
|
2593
|
+
|| typeof editor.snapshotId !== 'string'
|
|
2594
|
+
|| !editor.snapshotId
|
|
2595
|
+
|| !Number.isInteger(editor.editorVersion)
|
|
2596
|
+
|| editor.editorVersion < 1
|
|
2597
|
+
|| typeof editor.editorStateHash !== 'string'
|
|
2598
|
+
|| !/^[a-f0-9]{64}$/i.test(editor.editorStateHash)
|
|
2599
|
+
|| editor.stateSource !== 'current_editor_snapshot'
|
|
2600
|
+
) {
|
|
2601
|
+
throw Object.assign(
|
|
2602
|
+
new Error('Clip does not have a verified canonical editor snapshot. Save and verify the editor state before continuing.'),
|
|
2603
|
+
{ exitCode: EXIT.SERVER, data: deliveryState },
|
|
2604
|
+
);
|
|
2605
|
+
}
|
|
2606
|
+
return editor;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function requireExactCurrentExport(deliveryState, editorState, options = {}) {
|
|
2610
|
+
const requestedExportId = options.requestedExportId
|
|
2611
|
+
? String(options.requestedExportId)
|
|
2612
|
+
: null;
|
|
2613
|
+
const selection = deliveryState?.selection;
|
|
2614
|
+
const selected = deliveryState?.selectedExport;
|
|
2615
|
+
const blockers = Array.isArray(deliveryState?.deliveryBlockers)
|
|
2616
|
+
? deliveryState.deliveryBlockers
|
|
2617
|
+
: [];
|
|
2618
|
+
const selectedBlockers = Array.isArray(selected?.blockers) ? selected.blockers : [];
|
|
2619
|
+
const responseRequestedExportId = selection?.requestedExportId ?? null;
|
|
2620
|
+
const exact = Boolean(
|
|
2621
|
+
selection?.status === 'selected'
|
|
2622
|
+
&& selected
|
|
2623
|
+
&& typeof selected.exportId === 'string'
|
|
2624
|
+
&& selected.exportId
|
|
2625
|
+
&& selection.selectedExportId === selected.exportId
|
|
2626
|
+
&& responseRequestedExportId === requestedExportId
|
|
2627
|
+
&& (!requestedExportId || selected.exportId === requestedExportId)
|
|
2628
|
+
&& selected.snapshotId === editorState.snapshotId
|
|
2629
|
+
&& selected.editorVersion === editorState.editorVersion
|
|
2630
|
+
&& selected.editorStateHash === editorState.editorStateHash
|
|
2631
|
+
&& selected.exactlyMatchesEditor === true
|
|
2632
|
+
&& selected.inspectionStatus === 'verified'
|
|
2633
|
+
&& typeof selected.outputObjectFingerprint === 'string'
|
|
2634
|
+
&& /^[a-f0-9]{64}$/i.test(selected.outputObjectFingerprint)
|
|
2635
|
+
&& selectedBlockers.length === 0
|
|
2636
|
+
);
|
|
2637
|
+
const publishReady = !options.requireReadyToPublish
|
|
2638
|
+
|| (deliveryState?.readyToPublish === true && blockers.length === 0);
|
|
2639
|
+
if (!exact || !publishReady) {
|
|
2640
|
+
const detail = blockers.length
|
|
2641
|
+
? blockers.join(' ')
|
|
2642
|
+
: `Selection status is ${selection?.status || 'unknown'}.`;
|
|
2643
|
+
throw Object.assign(
|
|
2644
|
+
new Error(`Clip does not have one verified exact-current export: ${detail}`),
|
|
2645
|
+
{ exitCode: EXIT.SERVER, data: deliveryState },
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
return selected;
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
async function fetchCanonicalClipDeliveryState(config, options, clipId, exportId) {
|
|
2652
|
+
return apiFetch(
|
|
2653
|
+
config,
|
|
2654
|
+
options,
|
|
2655
|
+
'GET',
|
|
2656
|
+
`/api/v1/clips/${encodeURIComponent(clipId)}/delivery-state${queryString({ exportId })}`,
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
|
|
1914
2660
|
async function clips(config, options, action, args) {
|
|
1915
2661
|
if (action === 'list') {
|
|
1916
2662
|
output(await apiFetch(config, options, 'GET', `/api/v1/clips${queryString({
|
|
@@ -1927,6 +2673,19 @@ async function clips(config, options, action, args) {
|
|
|
1927
2673
|
output(result, options);
|
|
1928
2674
|
return;
|
|
1929
2675
|
}
|
|
2676
|
+
if (action === 'delivery-state') {
|
|
2677
|
+
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
2678
|
+
const result = await apiFetch(
|
|
2679
|
+
config,
|
|
2680
|
+
options,
|
|
2681
|
+
'GET',
|
|
2682
|
+
`/api/v1/clips/${encodeURIComponent(args[0])}/delivery-state${queryString({
|
|
2683
|
+
exportId: options['export-id'],
|
|
2684
|
+
})}`,
|
|
2685
|
+
);
|
|
2686
|
+
output(wantJson(options) ? result : formatClipDeliveryState(result), options);
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
1930
2689
|
if (action === 'create') {
|
|
1931
2690
|
const body = options.params
|
|
1932
2691
|
? await readJsonOption(String(options.params))
|
|
@@ -1994,9 +2753,34 @@ async function clips(config, options, action, args) {
|
|
|
1994
2753
|
}
|
|
1995
2754
|
if (action === 'download') {
|
|
1996
2755
|
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
1997
|
-
const
|
|
2756
|
+
const clipId = args[0];
|
|
2757
|
+
const requestedExportId = options['export-id'];
|
|
2758
|
+
const deliveryState = await fetchCanonicalClipDeliveryState(
|
|
2759
|
+
config,
|
|
2760
|
+
options,
|
|
2761
|
+
clipId,
|
|
2762
|
+
requestedExportId,
|
|
2763
|
+
);
|
|
2764
|
+
const editorState = requireVerifiedEditorState(deliveryState, clipId);
|
|
2765
|
+
const selectedExport = requireExactCurrentExport(deliveryState, editorState, {
|
|
2766
|
+
requestedExportId,
|
|
2767
|
+
});
|
|
2768
|
+
const result = await apiFetch(
|
|
2769
|
+
config,
|
|
2770
|
+
options,
|
|
2771
|
+
'GET',
|
|
2772
|
+
`/api/v1/exports/${encodeURIComponent(selectedExport.exportId)}/download`,
|
|
2773
|
+
);
|
|
1998
2774
|
if (options.open && result?.downloadUrl) openBrowser(result.downloadUrl);
|
|
1999
|
-
output(
|
|
2775
|
+
output({
|
|
2776
|
+
...result,
|
|
2777
|
+
clipId,
|
|
2778
|
+
exportId: selectedExport.exportId,
|
|
2779
|
+
snapshotId: editorState.snapshotId,
|
|
2780
|
+
editorVersion: editorState.editorVersion,
|
|
2781
|
+
editorStateHash: editorState.editorStateHash,
|
|
2782
|
+
outputObjectFingerprint: selectedExport.outputObjectFingerprint,
|
|
2783
|
+
}, options);
|
|
2000
2784
|
return;
|
|
2001
2785
|
}
|
|
2002
2786
|
if (action === 'delete') {
|
|
@@ -2139,6 +2923,17 @@ function defaultExportStartBody(clipId) {
|
|
|
2139
2923
|
};
|
|
2140
2924
|
}
|
|
2141
2925
|
|
|
2926
|
+
function buildCanonicalExportStartBody(clipId, params, editorState, idempotencyKey) {
|
|
2927
|
+
return {
|
|
2928
|
+
...defaultExportStartBody(clipId),
|
|
2929
|
+
...params,
|
|
2930
|
+
clipId,
|
|
2931
|
+
idempotencyKey,
|
|
2932
|
+
expectedEditorVersion: editorState.editorVersion,
|
|
2933
|
+
expectedEditorStateHash: editorState.editorStateHash,
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2142
2937
|
async function pollExport(config, options, jobId) {
|
|
2143
2938
|
const startedAt = Date.now();
|
|
2144
2939
|
const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
|
|
@@ -2161,14 +2956,32 @@ async function exportsCommand(config, options, action, args) {
|
|
|
2161
2956
|
if (action === 'start') {
|
|
2162
2957
|
const clipId = requiredString(options['clip-id'], '--clip-id');
|
|
2163
2958
|
const params = options.params ? await readJsonOption(String(options.params)) : {};
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2959
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
|
2960
|
+
throw Object.assign(new Error('--params must contain a JSON object.'), { exitCode: EXIT.USAGE });
|
|
2961
|
+
}
|
|
2962
|
+
const deliveryState = await fetchCanonicalClipDeliveryState(config, options, clipId);
|
|
2963
|
+
const editorState = requireVerifiedEditorState(deliveryState, clipId);
|
|
2964
|
+
const idempotencyKey = requiredString(
|
|
2965
|
+
options['idempotency-key'] || params.idempotencyKey || `cli-export:${randomUUID()}`,
|
|
2966
|
+
'--idempotency-key',
|
|
2967
|
+
);
|
|
2968
|
+
const body = buildCanonicalExportStartBody(
|
|
2167
2969
|
clipId,
|
|
2168
|
-
|
|
2970
|
+
params,
|
|
2971
|
+
editorState,
|
|
2972
|
+
idempotencyKey,
|
|
2973
|
+
);
|
|
2169
2974
|
await enforceMaxCredits(config, options, 'exports start', { clipId, body });
|
|
2170
2975
|
confirmPaid(options, 'Starting an export');
|
|
2171
|
-
|
|
2976
|
+
const result = await apiFetch(config, options, 'POST', '/api/v1/exports', body);
|
|
2977
|
+
output({
|
|
2978
|
+
...result,
|
|
2979
|
+
clipId,
|
|
2980
|
+
snapshotId: editorState.snapshotId,
|
|
2981
|
+
expectedEditorVersion: body.expectedEditorVersion,
|
|
2982
|
+
expectedEditorStateHash: body.expectedEditorStateHash,
|
|
2983
|
+
idempotencyKey: body.idempotencyKey,
|
|
2984
|
+
}, options);
|
|
2172
2985
|
return;
|
|
2173
2986
|
}
|
|
2174
2987
|
if (action === 'list') {
|
|
@@ -2200,32 +3013,76 @@ async function exportsCommand(config, options, action, args) {
|
|
|
2200
3013
|
throw Object.assign(new Error(`Unknown exports command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
2201
3014
|
}
|
|
2202
3015
|
|
|
2203
|
-
function
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
async function putSignedUpload(uploadUrl, filePath, contentType, size, options) {
|
|
2208
|
-
let response;
|
|
2209
|
-
const progress = createUploadProgress(options, size);
|
|
2210
|
-
const body = createReadStream(filePath).pipe(progressTransform(progress));
|
|
2211
|
-
try {
|
|
2212
|
-
response = await fetch(uploadUrl, {
|
|
2213
|
-
method: 'PUT',
|
|
2214
|
-
headers: {
|
|
3016
|
+
async function putSignedUpload(uploadUrl, filePath, contentType, size, options, requiredHeaders) {
|
|
3017
|
+
const headers = requiredHeaders && typeof requiredHeaders === 'object'
|
|
3018
|
+
? Object.fromEntries(Object.entries(requiredHeaders).map(([key, value]) => [key, String(value)]))
|
|
3019
|
+
: {
|
|
2215
3020
|
'Content-Type': contentType,
|
|
2216
3021
|
'Content-Length': String(size),
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
3022
|
+
};
|
|
3023
|
+
const requiredContentType = headers['Content-Type'] ?? headers['content-type'];
|
|
3024
|
+
const requiredContentLength = headers['Content-Length'] ?? headers['content-length'];
|
|
3025
|
+
if (requiredContentType !== contentType || requiredContentLength !== String(size)) {
|
|
3026
|
+
throw Object.assign(
|
|
3027
|
+
new Error('Upload signing response did not preserve the requested Content-Type and Content-Length headers.'),
|
|
3028
|
+
{ exitCode: EXIT.SERVER },
|
|
3029
|
+
);
|
|
2225
3030
|
}
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
3031
|
+
|
|
3032
|
+
for (let attempt = 0; attempt < VIDEO_UPLOAD_PART_ATTEMPTS; attempt += 1) {
|
|
3033
|
+
const progress = createUploadProgress(options, size);
|
|
3034
|
+
const controller = new AbortController();
|
|
3035
|
+
let source;
|
|
3036
|
+
let tracker;
|
|
3037
|
+
const watchdog = createUploadActivityWatchdog(controller, () => {
|
|
3038
|
+
source?.destroy();
|
|
3039
|
+
tracker?.destroy();
|
|
3040
|
+
});
|
|
3041
|
+
try {
|
|
3042
|
+
tracker = new Transform({
|
|
3043
|
+
transform(chunk, encoding, callback) {
|
|
3044
|
+
watchdog.activity();
|
|
3045
|
+
progress.track(chunk);
|
|
3046
|
+
callback(null, chunk);
|
|
3047
|
+
},
|
|
3048
|
+
});
|
|
3049
|
+
source = createReadStream(filePath);
|
|
3050
|
+
const response = await fetch(uploadUrl, {
|
|
3051
|
+
method: 'PUT',
|
|
3052
|
+
headers,
|
|
3053
|
+
body: source.pipe(tracker),
|
|
3054
|
+
duplex: 'half',
|
|
3055
|
+
signal: controller.signal,
|
|
3056
|
+
});
|
|
3057
|
+
if (!response.ok) {
|
|
3058
|
+
const text = await response.text().catch(() => '');
|
|
3059
|
+
throw Object.assign(
|
|
3060
|
+
new Error(`Upload failed: ${response.status} ${redact(text || response.statusText)}`),
|
|
3061
|
+
{ exitCode: EXIT.SERVER, status: response.status },
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
return;
|
|
3065
|
+
} catch (error) {
|
|
3066
|
+
const reason = controller.signal.aborted && controller.signal.reason instanceof Error
|
|
3067
|
+
? controller.signal.reason
|
|
3068
|
+
: error;
|
|
3069
|
+
const retryable = reason?.status === undefined
|
|
3070
|
+
|| reason.status === 408
|
|
3071
|
+
|| reason.status === 429
|
|
3072
|
+
|| reason.status >= 500;
|
|
3073
|
+
if (attempt === VIDEO_UPLOAD_PART_ATTEMPTS - 1 || !retryable) {
|
|
3074
|
+
throw Object.assign(new Error(`Upload failed: ${reason.message}`), {
|
|
3075
|
+
exitCode: reason.exitCode || EXIT.NETWORK,
|
|
3076
|
+
status: reason.status,
|
|
3077
|
+
});
|
|
3078
|
+
}
|
|
3079
|
+
await sleep(VIDEO_UPLOAD_RETRY_BASE_MS * (2 ** attempt));
|
|
3080
|
+
} finally {
|
|
3081
|
+
watchdog.stop();
|
|
3082
|
+
source?.destroy();
|
|
3083
|
+
tracker?.destroy();
|
|
3084
|
+
progress.finish();
|
|
3085
|
+
}
|
|
2229
3086
|
}
|
|
2230
3087
|
}
|
|
2231
3088
|
|
|
@@ -2242,28 +3099,35 @@ async function uploadAsset(config, options, filePath) {
|
|
|
2242
3099
|
contentType,
|
|
2243
3100
|
size: stat.size,
|
|
2244
3101
|
kind: options.kind,
|
|
3102
|
+
idempotencyKey: `cli-library:${randomUUID()}`,
|
|
2245
3103
|
});
|
|
2246
3104
|
requireConfirm(options, 'Uploading an asset');
|
|
2247
3105
|
const signed = await apiFetch(config, options, 'POST', '/api/v1/assets/sign-upload', signBody);
|
|
2248
|
-
|
|
3106
|
+
const uploadUrl = signed?.uploadUrl || signed?.url;
|
|
3107
|
+
if (!uploadUrl || !signed?.intentId || !signed?.assetId) {
|
|
2249
3108
|
throw Object.assign(new Error('Asset sign-upload returned an unexpected response.'), { exitCode: EXIT.SERVER, data: signed });
|
|
2250
3109
|
}
|
|
2251
|
-
await putSignedUpload(
|
|
2252
|
-
const objectPath = objectPathFromKey(signed.key);
|
|
3110
|
+
await putSignedUpload(uploadUrl, resolved, contentType, stat.size, options);
|
|
2253
3111
|
output(await apiFetch(config, options, 'POST', `/api/v1/assets/${encodeURIComponent(signed.assetId)}/finalize`, {
|
|
2254
|
-
|
|
2255
|
-
fileSize: stat.size,
|
|
2256
|
-
duration: options.duration === undefined ? null : numberOption(options.duration, '--duration'),
|
|
3112
|
+
uploadIntentId: signed.intentId,
|
|
2257
3113
|
}), options);
|
|
2258
3114
|
}
|
|
2259
3115
|
|
|
2260
3116
|
async function assets(config, options, action, args) {
|
|
2261
3117
|
if (action === 'list') {
|
|
2262
|
-
|
|
3118
|
+
const response = await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
|
|
2263
3119
|
type: options.type,
|
|
2264
3120
|
limit: options.limit,
|
|
2265
3121
|
offset: options.offset,
|
|
2266
|
-
})}
|
|
3122
|
+
})}`, undefined, { includeResponseMetadata: true });
|
|
3123
|
+
const items = Array.isArray(response.data) ? response.data : [];
|
|
3124
|
+
const headerTotal = Number(response.headers['x-total-count']);
|
|
3125
|
+
output({
|
|
3126
|
+
items,
|
|
3127
|
+
total: Number.isFinite(headerTotal) ? headerTotal : items.length,
|
|
3128
|
+
limit: numberOption(options.limit, '--limit') ?? items.length,
|
|
3129
|
+
offset: numberOption(options.offset, '--offset') ?? 0,
|
|
3130
|
+
}, options);
|
|
2267
3131
|
return;
|
|
2268
3132
|
}
|
|
2269
3133
|
if (action === 'upload') {
|
|
@@ -2370,13 +3234,83 @@ function socialPlatformList(value) {
|
|
|
2370
3234
|
return platforms.map((platform) => platform.toLowerCase() === 'x' ? 'twitter' : platform);
|
|
2371
3235
|
}
|
|
2372
3236
|
|
|
2373
|
-
function
|
|
3237
|
+
function socialAccountIdPins(value) {
|
|
3238
|
+
if (!value) return {};
|
|
3239
|
+
const raw = String(value).trim();
|
|
3240
|
+
if (!raw) return {};
|
|
3241
|
+
if (raw.startsWith('{')) {
|
|
3242
|
+
const parsed = JSON.parse(raw);
|
|
3243
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
3244
|
+
throw Object.assign(new Error('--account-ids JSON must be an object of platform to account id.'), { exitCode: EXIT.USAGE });
|
|
3245
|
+
}
|
|
3246
|
+
return Object.fromEntries(Object.entries(parsed).map(([platform, accountId]) => [
|
|
3247
|
+
platform.toLowerCase() === 'x' ? 'twitter' : platform.toLowerCase(),
|
|
3248
|
+
requiredString(accountId, `account id for ${platform}`),
|
|
3249
|
+
]));
|
|
3250
|
+
}
|
|
3251
|
+
return Object.fromEntries(raw.split(',').filter(Boolean).map((entry) => {
|
|
3252
|
+
const separator = entry.indexOf('=');
|
|
3253
|
+
if (separator <= 0 || separator === entry.length - 1) {
|
|
3254
|
+
throw Object.assign(new Error('--account-ids must use platform=accountId pairs.'), { exitCode: EXIT.USAGE });
|
|
3255
|
+
}
|
|
3256
|
+
const platform = entry.slice(0, separator).trim().toLowerCase();
|
|
3257
|
+
const accountId = entry.slice(separator + 1).trim();
|
|
3258
|
+
return [platform === 'x' ? 'twitter' : platform, accountId];
|
|
3259
|
+
}));
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
async function socialPostBody(config, options, scheduled) {
|
|
3263
|
+
const clipId = requiredString(options['clip-id'], '--clip-id');
|
|
3264
|
+
const platforms = socialPlatformList(options.platforms);
|
|
3265
|
+
const caption = requiredString(options.caption, '--caption');
|
|
3266
|
+
const requestedExportId = options['export-id'];
|
|
3267
|
+
const deliveryState = await fetchCanonicalClipDeliveryState(
|
|
3268
|
+
config,
|
|
3269
|
+
options,
|
|
3270
|
+
clipId,
|
|
3271
|
+
requestedExportId,
|
|
3272
|
+
);
|
|
3273
|
+
const editorState = requireVerifiedEditorState(deliveryState, clipId);
|
|
3274
|
+
const selectedExport = requireExactCurrentExport(deliveryState, editorState, {
|
|
3275
|
+
requestedExportId,
|
|
3276
|
+
requireReadyToPublish: true,
|
|
3277
|
+
});
|
|
3278
|
+
const accountsResponse = await apiFetch(config, options, 'GET', '/api/v1/social/accounts');
|
|
3279
|
+
const connectedAccounts = Array.isArray(accountsResponse?.accounts)
|
|
3280
|
+
? accountsResponse.accounts.filter((account) => account?.connected && typeof account?.accountId === 'string')
|
|
3281
|
+
: [];
|
|
3282
|
+
const requestedAccountIds = socialAccountIdPins(options['account-ids']);
|
|
3283
|
+
const expectedAccountIds = Object.fromEntries(platforms.map((platform) => {
|
|
3284
|
+
const choices = Array.from(new Set(connectedAccounts
|
|
3285
|
+
.filter((account) => account.platform === platform)
|
|
3286
|
+
.map((account) => account.accountId)));
|
|
3287
|
+
const requestedAccountId = requestedAccountIds[platform];
|
|
3288
|
+
if (requestedAccountId) {
|
|
3289
|
+
if (!choices.includes(requestedAccountId)) {
|
|
3290
|
+
throw Object.assign(new Error(`The selected ${platform} account id is not connected.`), { exitCode: EXIT.USAGE });
|
|
3291
|
+
}
|
|
3292
|
+
return [platform, requestedAccountId];
|
|
3293
|
+
}
|
|
3294
|
+
if (choices.length !== 1) {
|
|
3295
|
+
const reason = choices.length === 0 ? 'no connected account' : 'multiple connected accounts';
|
|
3296
|
+
throw Object.assign(
|
|
3297
|
+
new Error(`${platform} has ${reason}. Run "clipit social accounts --json" and pass --account-ids ${platform}=<accountId>.`),
|
|
3298
|
+
{ exitCode: EXIT.USAGE },
|
|
3299
|
+
);
|
|
3300
|
+
}
|
|
3301
|
+
return [platform, choices[0]];
|
|
3302
|
+
}));
|
|
2374
3303
|
const body = compactObject({
|
|
2375
|
-
clipId
|
|
2376
|
-
platforms
|
|
2377
|
-
caption
|
|
3304
|
+
clipId,
|
|
3305
|
+
platforms,
|
|
3306
|
+
caption,
|
|
2378
3307
|
title: options.title,
|
|
2379
3308
|
hashtags: stringList(options.hashtags),
|
|
3309
|
+
exportId: selectedExport.exportId,
|
|
3310
|
+
expectedSnapshotId: editorState.snapshotId,
|
|
3311
|
+
expectedOutputObjectFingerprint: selectedExport.outputObjectFingerprint,
|
|
3312
|
+
expectedAccountIds,
|
|
3313
|
+
publishExactCurrentArtifact: true,
|
|
2380
3314
|
});
|
|
2381
3315
|
if (scheduled) body.scheduledFor = requiredString(options.at, '--at');
|
|
2382
3316
|
return body;
|
|
@@ -2388,14 +3322,14 @@ async function social(config, options, action, args) {
|
|
|
2388
3322
|
return;
|
|
2389
3323
|
}
|
|
2390
3324
|
if (action === 'post') {
|
|
2391
|
-
const body = socialPostBody(options, false);
|
|
3325
|
+
const body = await socialPostBody(config, options, false);
|
|
2392
3326
|
await enforceMaxCredits(config, options, 'social post', { body });
|
|
2393
3327
|
confirmPaid(options, 'Publishing a social post');
|
|
2394
3328
|
output(await apiFetch(config, options, 'POST', '/api/v1/social/post', body), options);
|
|
2395
3329
|
return;
|
|
2396
3330
|
}
|
|
2397
3331
|
if (action === 'schedule') {
|
|
2398
|
-
const body = socialPostBody(options, true);
|
|
3332
|
+
const body = await socialPostBody(config, options, true);
|
|
2399
3333
|
await enforceMaxCredits(config, options, 'social schedule', { body });
|
|
2400
3334
|
confirmPaid(options, 'Scheduling a social post');
|
|
2401
3335
|
output(await apiFetch(config, options, 'POST', '/api/v1/social/schedule', body), options);
|
|
@@ -2437,6 +3371,19 @@ async function jobs(config, options, action, args) {
|
|
|
2437
3371
|
while (true) {
|
|
2438
3372
|
const job = await apiFetch(config, options, 'GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`);
|
|
2439
3373
|
if (action === 'get' || TERMINAL_JOB_STATUSES.has(job.status)) {
|
|
3374
|
+
const videoId = job.videoId || job.result?.videoId;
|
|
3375
|
+
const clipId = job.clipId || job.result?.clipId;
|
|
3376
|
+
if (videoId || clipId) {
|
|
3377
|
+
await persistActiveContext(
|
|
3378
|
+
config,
|
|
3379
|
+
options,
|
|
3380
|
+
compactObject({ videoId, clipId }),
|
|
3381
|
+
[
|
|
3382
|
+
videoId ? { type: 'video', id: videoId } : null,
|
|
3383
|
+
clipId ? { type: 'clip', id: clipId } : null,
|
|
3384
|
+
].filter(Boolean),
|
|
3385
|
+
);
|
|
3386
|
+
}
|
|
2440
3387
|
output(job, options);
|
|
2441
3388
|
return;
|
|
2442
3389
|
}
|
|
@@ -3029,7 +3976,7 @@ async function examples(options) {
|
|
|
3029
3976
|
uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --confirm --json',
|
|
3030
3977
|
thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
|
|
3031
3978
|
brollPlan: 'clipit broll plan <clipId> --count 3 --confirm --json',
|
|
3032
|
-
socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json',
|
|
3979
|
+
socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json',
|
|
3033
3980
|
runTool: 'clipit run <functionName> --clip-id <clipId> --params @params.json --json',
|
|
3034
3981
|
reviewLink: 'clipit links clip <clipId> --json',
|
|
3035
3982
|
};
|
|
@@ -3062,6 +4009,7 @@ Rules:
|
|
|
3062
4009
|
- Credits from subscriptions and top-ups stack; paid features are credit-gated, so verify the payment receipt or balance before running paid ClipIt tools.
|
|
3063
4010
|
- Use \`clipit open clip <id>\` when the user should review work in ClipIt.
|
|
3064
4011
|
- Use \`clipit context use --video-id <id>\` or \`clipit context use --clip-id <id>\` to persist the current target for later commands.
|
|
4012
|
+
- Run \`clipit social accounts --json\` before posting. If a platform has multiple connected accounts, pin the intended one with \`--account-ids platform=accountId\`; ClipIt will not guess.
|
|
3065
4013
|
- Do not write API keys into this skill file or any project files.
|
|
3066
4014
|
|
|
3067
4015
|
Useful commands:
|
|
@@ -3095,7 +4043,7 @@ clipit billing receipt <attemptId> --json
|
|
|
3095
4043
|
clipit analytics overview --days 30 --json
|
|
3096
4044
|
clipit exports start --clip-id <clipId> --confirm --json
|
|
3097
4045
|
clipit thumbnails generate --clip-id <clipId> --prompt "High contrast thumbnail" --confirm --json
|
|
3098
|
-
clipit social post --clip-id <clipId> --platforms x,tiktok --caption "New clip" --confirm --json
|
|
4046
|
+
clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json
|
|
3099
4047
|
clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --confirm --json
|
|
3100
4048
|
\`\`\`
|
|
3101
4049
|
|
|
@@ -3377,6 +4325,7 @@ async function main() {
|
|
|
3377
4325
|
if (command === 'auth' && subcommand === 'set-key') return setKey(config, options);
|
|
3378
4326
|
if (command === 'auth' && subcommand === 'open-settings') return openCommand(config, options, 'settings');
|
|
3379
4327
|
if (command === 'auth' && subcommand === 'profiles') return listProfiles(config, options);
|
|
4328
|
+
if (command === 'auth' && subcommand === 'use') return useProfile(config, options, rest[0]);
|
|
3380
4329
|
if (command === 'context') return contextCommand(config, options, subcommand);
|
|
3381
4330
|
if (command === 'skills' && subcommand === 'list') return listSkills(config, options);
|
|
3382
4331
|
if (command === 'tools' && subcommand === 'list') return listTools(config, options);
|
|
@@ -3416,6 +4365,9 @@ function handleMainError(error) {
|
|
|
3416
4365
|
}), null, 2));
|
|
3417
4366
|
} else {
|
|
3418
4367
|
console.error(message);
|
|
4368
|
+
const details = redactDeep(error.data);
|
|
4369
|
+
if (details?.resumeCommand) console.error(`Resume with: ${details.resumeCommand}`);
|
|
4370
|
+
if (details?.abortCommand) console.error(`Abort the prior upload with: ${details.abortCommand}`);
|
|
3419
4371
|
}
|
|
3420
4372
|
process.exit(exitCode);
|
|
3421
4373
|
}
|
|
@@ -3438,4 +4390,18 @@ if (await isDirectRun()) {
|
|
|
3438
4390
|
main().catch(handleMainError);
|
|
3439
4391
|
}
|
|
3440
4392
|
|
|
3441
|
-
export {
|
|
4393
|
+
export {
|
|
4394
|
+
buildCanonicalExportStartBody,
|
|
4395
|
+
main,
|
|
4396
|
+
redact,
|
|
4397
|
+
clipCostLabel,
|
|
4398
|
+
requireExactCurrentExport,
|
|
4399
|
+
requireVerifiedEditorState,
|
|
4400
|
+
handleMcpRequest,
|
|
4401
|
+
readPositiveIntegerEnv,
|
|
4402
|
+
readSecretLine,
|
|
4403
|
+
readVideoUploadResumes,
|
|
4404
|
+
removeVideoUploadResume,
|
|
4405
|
+
saveVideoUploadResume,
|
|
4406
|
+
shellQuote,
|
|
4407
|
+
};
|