@clipit-ai/cli 0.2.4 → 0.2.7
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 +1121 -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.7';
|
|
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|initialize-snapshot|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'));
|
|
@@ -1076,6 +1251,13 @@ async function enforceRunMaxCredits(config, options, functionName, parameters =
|
|
|
1076
1251
|
const isMetered = Boolean(tool?.costBand && tool.costBand !== 'free');
|
|
1077
1252
|
const isMeteredExempt = RUN_METERED_CONFIRMATION_EXEMPTIONS.has(functionName);
|
|
1078
1253
|
|
|
1254
|
+
if (tool?.costBand === 'free') {
|
|
1255
|
+
if (tool?.confirmation?.required) {
|
|
1256
|
+
requireConfirm(options, `Running confirmation-gated tool ${functionName}`);
|
|
1257
|
+
}
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1079
1261
|
let staticEstimates = null;
|
|
1080
1262
|
if (!runEstimate && (limit !== null || needsConfirmationPreflight)) {
|
|
1081
1263
|
staticEstimates = await buildStaticRunEstimates(config, options, functionName, parameters, payload);
|
|
@@ -1294,7 +1476,7 @@ async function setKey(config, options) {
|
|
|
1294
1476
|
if (!options.stdin) {
|
|
1295
1477
|
throw Object.assign(new Error('Use --stdin to avoid shell history leaks.'), { exitCode: EXIT.USAGE });
|
|
1296
1478
|
}
|
|
1297
|
-
const apiKey = (await
|
|
1479
|
+
const apiKey = (await readSecretLine()).trim();
|
|
1298
1480
|
if (!apiKey) {
|
|
1299
1481
|
throw Object.assign(new Error('No API key received on stdin.'), { exitCode: EXIT.USAGE });
|
|
1300
1482
|
}
|
|
@@ -1303,17 +1485,50 @@ async function setKey(config, options) {
|
|
|
1303
1485
|
apiKey,
|
|
1304
1486
|
loginSource: 'manual',
|
|
1305
1487
|
});
|
|
1306
|
-
const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me');
|
|
1307
|
-
await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey }));
|
|
1308
|
-
output({
|
|
1488
|
+
const me = await apiFetch(nextConfig, options, 'GET', '/api/v1/agent/me', undefined, { authApiKey: apiKey });
|
|
1489
|
+
await writeConfig(updateProfile(nextConfig, options, { keyInfo: me.apiKey, scope: me.scope ?? null }));
|
|
1490
|
+
output({
|
|
1491
|
+
success: true,
|
|
1492
|
+
message: 'API key stored',
|
|
1493
|
+
profile: profileName(config, options),
|
|
1494
|
+
account: me.user,
|
|
1495
|
+
apiKey: me.apiKey,
|
|
1496
|
+
scope: me.scope ?? null,
|
|
1497
|
+
}, options);
|
|
1309
1498
|
}
|
|
1310
1499
|
|
|
1311
1500
|
async function logout(config, options) {
|
|
1312
|
-
const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'loginSource']);
|
|
1501
|
+
const next = removeProfileFields(config, options, ['apiKey', 'keyInfo', 'scope', 'loginSource']);
|
|
1313
1502
|
await writeConfig(next);
|
|
1314
1503
|
output({ success: true, message: 'Local ClipIt CLI credentials removed', profile: profileName(config, options) }, options);
|
|
1315
1504
|
}
|
|
1316
1505
|
|
|
1506
|
+
async function useProfile(config, options, requestedName) {
|
|
1507
|
+
const name = String(requestedName || '').trim();
|
|
1508
|
+
if (!name) {
|
|
1509
|
+
throw Object.assign(new Error('Profile name is required.'), { exitCode: EXIT.USAGE });
|
|
1510
|
+
}
|
|
1511
|
+
if (process.env.CLIPIT_PROFILE && process.env.CLIPIT_PROFILE !== name) {
|
|
1512
|
+
throw Object.assign(
|
|
1513
|
+
new Error(`CLIPIT_PROFILE is set to ${process.env.CLIPIT_PROFILE}; unset it or select that profile explicitly.`),
|
|
1514
|
+
{ exitCode: EXIT.USAGE },
|
|
1515
|
+
);
|
|
1516
|
+
}
|
|
1517
|
+
const selectedOptions = { ...options, profile: name };
|
|
1518
|
+
const profile = profileData(config, selectedOptions);
|
|
1519
|
+
if (!profile.apiKey) {
|
|
1520
|
+
throw Object.assign(new Error(`Profile ${name} does not have a stored credential.`), { exitCode: EXIT.AUTH });
|
|
1521
|
+
}
|
|
1522
|
+
const next = { ...config, currentProfile: name, updatedAt: new Date().toISOString() };
|
|
1523
|
+
await writeConfig(next);
|
|
1524
|
+
output({
|
|
1525
|
+
success: true,
|
|
1526
|
+
currentProfile: name,
|
|
1527
|
+
keyName: profile.keyInfo?.keyName ?? null,
|
|
1528
|
+
scope: profile.scope ?? null,
|
|
1529
|
+
}, options);
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1317
1532
|
async function listProfiles(config, options) {
|
|
1318
1533
|
const profiles = config.profiles || {};
|
|
1319
1534
|
const names = [...new Set(['default', ...Object.keys(profiles)])];
|
|
@@ -1329,6 +1544,7 @@ async function listProfiles(config, options) {
|
|
|
1329
1544
|
hasCredential: Boolean(data.apiKey),
|
|
1330
1545
|
loginSource: data.loginSource || null,
|
|
1331
1546
|
keyName: data.keyInfo?.keyName || null,
|
|
1547
|
+
scope: data.scope || null,
|
|
1332
1548
|
updatedAt: data.updatedAt || null,
|
|
1333
1549
|
};
|
|
1334
1550
|
}),
|
|
@@ -1336,6 +1552,7 @@ async function listProfiles(config, options) {
|
|
|
1336
1552
|
}
|
|
1337
1553
|
|
|
1338
1554
|
async function doctor(config, options) {
|
|
1555
|
+
const credential = resolveApiCredential(config, options);
|
|
1339
1556
|
const checks = {
|
|
1340
1557
|
version: VERSION,
|
|
1341
1558
|
node: process.version,
|
|
@@ -1343,8 +1560,8 @@ async function doctor(config, options) {
|
|
|
1343
1560
|
configPath: configPath(),
|
|
1344
1561
|
profile: profileName(config, options),
|
|
1345
1562
|
baseUrl: getBaseUrl(config, options),
|
|
1346
|
-
hasCredential: Boolean(
|
|
1347
|
-
credentialSource:
|
|
1563
|
+
hasCredential: Boolean(credential.apiKey),
|
|
1564
|
+
credentialSource: credential.source,
|
|
1348
1565
|
auth: null,
|
|
1349
1566
|
};
|
|
1350
1567
|
try {
|
|
@@ -1650,11 +1867,33 @@ async function pollWorkflow(config, options, jobId) {
|
|
|
1650
1867
|
}
|
|
1651
1868
|
}
|
|
1652
1869
|
|
|
1870
|
+
function promptNamesNewSourceUrl(userMessage) {
|
|
1871
|
+
const urls = userMessage.match(/https?:\/\/[^\s<>"'`]+/gi) || [];
|
|
1872
|
+
if (urls.length !== 1) return false;
|
|
1873
|
+
const messageWithoutUrls = userMessage
|
|
1874
|
+
.replace(/https?:\/\/[^\s<>"'`]+/gi, ' ')
|
|
1875
|
+
.replace(/[^a-z0-9]+/gi, ' ')
|
|
1876
|
+
.trim()
|
|
1877
|
+
.toLowerCase();
|
|
1878
|
+
const isUrlOnly = messageWithoutUrls.length === 0;
|
|
1879
|
+
const hasStrongSourceIntent = /\b(import|process|source|transcrib\w*|download|extract)\b/.test(messageWithoutUrls)
|
|
1880
|
+
|| /\b(clip|clips|video)\b.*\b(from|using|this|that)\b/.test(messageWithoutUrls)
|
|
1881
|
+
|| /\b(use|using)\b.*\b(link|url|source|video|this|that)\b/.test(messageWithoutUrls);
|
|
1882
|
+
const isReferenceOnly = /\b(reference|inspiration|example|style)\b/.test(messageWithoutUrls)
|
|
1883
|
+
&& !/\b(import|process|source|transcrib\w*|clip|clips|extract)\b/.test(messageWithoutUrls);
|
|
1884
|
+
return isUrlOnly || (hasStrongSourceIntent && !isReferenceOnly);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1653
1887
|
async function askWorkflow(config, options, promptParts) {
|
|
1654
1888
|
const userMessage = promptParts.filter((part) => part !== undefined).join(' ').trim();
|
|
1655
1889
|
if (!userMessage) throw Object.assign(new Error('Prompt is required.'), { exitCode: EXIT.USAGE });
|
|
1656
1890
|
|
|
1657
1891
|
const context = await buildContext(config, options);
|
|
1892
|
+
if (promptNamesNewSourceUrl(userMessage)) {
|
|
1893
|
+
if (!options['video-id']) delete context.videoId;
|
|
1894
|
+
if (!options['clip-id']) delete context.clipId;
|
|
1895
|
+
if (!options['selected-clip-ids']) delete context.selectedClipIds;
|
|
1896
|
+
}
|
|
1658
1897
|
const payload = { userMessage };
|
|
1659
1898
|
for (const field of ['videoId', 'clipId', 'projectId', 'sequenceId']) {
|
|
1660
1899
|
if (context[field]) payload[field] = context[field];
|
|
@@ -1733,16 +1972,19 @@ function mimeForPath(filePath) {
|
|
|
1733
1972
|
if (ext === '.webm') return 'video/webm';
|
|
1734
1973
|
if (ext === '.mkv') return 'video/x-matroska';
|
|
1735
1974
|
if (ext === '.m4v') return 'video/x-m4v';
|
|
1975
|
+
if (ext === '.avi') return 'video/x-msvideo';
|
|
1976
|
+
if (ext === '.wmv' || ext === '.asf') return 'video/x-ms-asf';
|
|
1977
|
+
if (ext === '.flv') return 'video/x-flv';
|
|
1978
|
+
if (ext === '.mpeg' || ext === '.mpg') return 'video/mpeg';
|
|
1979
|
+
if (ext === '.ts' || ext === '.m2ts') return 'video/mp2t';
|
|
1980
|
+
if (ext === '.mxf') return 'video/mxf';
|
|
1981
|
+
if (ext === '.ogv') return 'video/ogg';
|
|
1736
1982
|
if (ext === '.mp3') return 'audio/mpeg';
|
|
1737
1983
|
if (ext === '.m4a') return 'audio/mp4';
|
|
1738
1984
|
if (ext === '.wav') return 'audio/wav';
|
|
1739
1985
|
return 'video/mp4';
|
|
1740
1986
|
}
|
|
1741
1987
|
|
|
1742
|
-
function multipartFilename(value) {
|
|
1743
|
-
return String(value).replace(/["\r\n]/g, '_');
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
1988
|
function shouldReportUploadProgress(options) {
|
|
1747
1989
|
return Boolean(process.stderr.isTTY) && !wantJson(options);
|
|
1748
1990
|
}
|
|
@@ -1751,24 +1993,6 @@ function formatMb(bytes) {
|
|
|
1751
1993
|
return (bytes / (1024 * 1024)).toFixed(1);
|
|
1752
1994
|
}
|
|
1753
1995
|
|
|
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
1996
|
function createUploadProgress(options, totalBytes) {
|
|
1773
1997
|
if (!shouldReportUploadProgress(options) || !Number.isFinite(totalBytes) || totalBytes <= 0) {
|
|
1774
1998
|
return { track() {}, finish() {} };
|
|
@@ -1797,15 +2021,259 @@ function createUploadProgress(options, totalBytes) {
|
|
|
1797
2021
|
};
|
|
1798
2022
|
}
|
|
1799
2023
|
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
2024
|
+
const VIDEO_UPLOAD_RESUME_MAX_AGE_MS = 25 * 60 * 60 * 1000;
|
|
2025
|
+
|
|
2026
|
+
function videoUploadResumeDirectory() {
|
|
2027
|
+
return path.join(configDir(), 'video-upload-resumes');
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function videoUploadResumePath(idempotencyKey) {
|
|
2031
|
+
const digest = createHash('sha256').update(String(idempotencyKey)).digest('hex');
|
|
2032
|
+
return path.join(videoUploadResumeDirectory(), `${digest}.json`);
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
async function readVideoUploadResumes() {
|
|
2036
|
+
const resumes = {};
|
|
2037
|
+
await fs.mkdir(videoUploadResumeDirectory(), { recursive: true, mode: 0o700 });
|
|
2038
|
+
const files = await fs.readdir(videoUploadResumeDirectory()).catch(() => []);
|
|
2039
|
+
for (const file of files) {
|
|
2040
|
+
if (!file.endsWith('.json')) continue;
|
|
2041
|
+
const recordPath = path.join(videoUploadResumeDirectory(), file);
|
|
2042
|
+
try {
|
|
2043
|
+
const value = JSON.parse(await fs.readFile(recordPath, 'utf8'));
|
|
2044
|
+
const updatedAt = Date.parse(value?.updatedAt);
|
|
2045
|
+
if (
|
|
2046
|
+
!value
|
|
2047
|
+
|| typeof value !== 'object'
|
|
2048
|
+
|| Array.isArray(value)
|
|
2049
|
+
|| typeof value.idempotencyKey !== 'string'
|
|
2050
|
+
|| !Number.isFinite(updatedAt)
|
|
2051
|
+
|| Date.now() - updatedAt > VIDEO_UPLOAD_RESUME_MAX_AGE_MS
|
|
2052
|
+
) {
|
|
2053
|
+
await fs.rm(recordPath, { force: true });
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
2056
|
+
resumes[value.idempotencyKey] = value;
|
|
2057
|
+
} catch {
|
|
2058
|
+
await fs.rm(recordPath, { force: true }).catch(() => undefined);
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
const legacyPath = path.join(configDir(), 'video-upload-resume.json');
|
|
2063
|
+
try {
|
|
2064
|
+
const legacy = JSON.parse(await fs.readFile(legacyPath, 'utf8'));
|
|
2065
|
+
if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
|
|
2066
|
+
for (const [idempotencyKey, value] of Object.entries(legacy)) {
|
|
2067
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
2068
|
+
await saveVideoUploadResume(idempotencyKey, value);
|
|
2069
|
+
resumes[idempotencyKey] = { ...value, idempotencyKey };
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
await fs.rm(legacyPath, { force: true });
|
|
2073
|
+
} catch {}
|
|
2074
|
+
|
|
2075
|
+
return resumes;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
async function saveVideoUploadResume(idempotencyKey, value) {
|
|
2079
|
+
await fs.mkdir(videoUploadResumeDirectory(), { recursive: true, mode: 0o700 });
|
|
2080
|
+
const destination = videoUploadResumePath(idempotencyKey);
|
|
2081
|
+
const temporary = `${destination}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
2082
|
+
await fs.writeFile(
|
|
2083
|
+
temporary,
|
|
2084
|
+
`${JSON.stringify({ ...value, idempotencyKey }, null, 2)}\n`,
|
|
2085
|
+
{ mode: 0o600 },
|
|
1803
2086
|
);
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
2087
|
+
await fs.rename(temporary, destination);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
async function removeVideoUploadResume(idempotencyKey) {
|
|
2091
|
+
await fs.rm(videoUploadResumePath(idempotencyKey), { force: true });
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
async function removeVideoUploadResumeByIntent(intentId) {
|
|
2095
|
+
const resumes = await readVideoUploadResumes();
|
|
2096
|
+
await Promise.all(Object.entries(resumes).map(async ([key, value]) => {
|
|
2097
|
+
if (value?.intentId === intentId) await removeVideoUploadResume(key);
|
|
2098
|
+
}));
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
function defaultVideoUploadIdempotencyKey(resolved, stat, filename, contentType) {
|
|
2102
|
+
const fingerprint = createHash('sha256').update(JSON.stringify({
|
|
2103
|
+
path: resolved,
|
|
2104
|
+
size: stat.size,
|
|
2105
|
+
mtimeMs: stat.mtimeMs,
|
|
2106
|
+
ctimeMs: stat.ctimeMs,
|
|
2107
|
+
filename,
|
|
2108
|
+
contentType,
|
|
2109
|
+
})).digest('hex');
|
|
2110
|
+
return `cli-video:${fingerprint}`;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
function createMultipartUploadProgress(options, totalBytes, completedParts) {
|
|
2114
|
+
if (!shouldReportUploadProgress(options) || totalBytes <= 0) {
|
|
2115
|
+
return { updatePart() {}, completePart() {}, resetPart() {}, finish() {} };
|
|
2116
|
+
}
|
|
2117
|
+
const completed = new Map(completedParts.map((part) => [part.partNumber, part.sizeBytes]));
|
|
2118
|
+
const inFlight = new Map();
|
|
2119
|
+
let lastPrintedAt = 0;
|
|
2120
|
+
|
|
2121
|
+
const print = (force = false) => {
|
|
2122
|
+
const now = Date.now();
|
|
2123
|
+
if (!force && now - lastPrintedAt < 500) return;
|
|
2124
|
+
lastPrintedAt = now;
|
|
2125
|
+
const uploaded = Math.min(
|
|
2126
|
+
totalBytes,
|
|
2127
|
+
[...completed.values(), ...inFlight.values()].reduce((sum, value) => sum + value, 0),
|
|
2128
|
+
);
|
|
2129
|
+
const percent = Math.min(100, Math.round((uploaded / totalBytes) * 100));
|
|
2130
|
+
process.stderr.write(`\ruploaded ${formatMb(uploaded)} / ${formatMb(totalBytes)} MB (${percent}%)`);
|
|
2131
|
+
};
|
|
2132
|
+
|
|
2133
|
+
return {
|
|
2134
|
+
updatePart(partNumber, bytes) {
|
|
2135
|
+
inFlight.set(partNumber, bytes);
|
|
2136
|
+
print(false);
|
|
2137
|
+
},
|
|
2138
|
+
completePart(partNumber, bytes) {
|
|
2139
|
+
inFlight.delete(partNumber);
|
|
2140
|
+
completed.set(partNumber, bytes);
|
|
2141
|
+
print(false);
|
|
2142
|
+
},
|
|
2143
|
+
resetPart(partNumber) {
|
|
2144
|
+
inFlight.delete(partNumber);
|
|
2145
|
+
print(false);
|
|
2146
|
+
},
|
|
2147
|
+
finish() {
|
|
2148
|
+
print(true);
|
|
2149
|
+
process.stderr.write('\n');
|
|
2150
|
+
},
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function createUploadActivityWatchdog(controller, onTimeout) {
|
|
2155
|
+
let timer;
|
|
2156
|
+
const arm = () => {
|
|
2157
|
+
if (timer) clearTimeout(timer);
|
|
2158
|
+
timer = setTimeout(() => {
|
|
2159
|
+
const error = Object.assign(
|
|
2160
|
+
new Error(`Storage upload made no progress for ${VIDEO_UPLOAD_STALL_TIMEOUT_MS} ms`),
|
|
2161
|
+
{ code: 'UPLOAD_STALLED' },
|
|
2162
|
+
);
|
|
2163
|
+
controller.abort(error);
|
|
2164
|
+
onTimeout?.();
|
|
2165
|
+
}, VIDEO_UPLOAD_STALL_TIMEOUT_MS);
|
|
2166
|
+
timer.unref?.();
|
|
2167
|
+
};
|
|
2168
|
+
arm();
|
|
2169
|
+
return {
|
|
2170
|
+
activity: arm,
|
|
2171
|
+
stop() {
|
|
2172
|
+
if (timer) clearTimeout(timer);
|
|
2173
|
+
timer = undefined;
|
|
2174
|
+
},
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
async function putSignedVideoPart(part, filePath, start, size, progress) {
|
|
2179
|
+
for (let attempt = 0; attempt < VIDEO_UPLOAD_PART_ATTEMPTS; attempt += 1) {
|
|
2180
|
+
let transferred = 0;
|
|
2181
|
+
const controller = new AbortController();
|
|
2182
|
+
let source;
|
|
2183
|
+
let tracker;
|
|
2184
|
+
const watchdog = createUploadActivityWatchdog(controller, () => {
|
|
2185
|
+
source?.destroy();
|
|
2186
|
+
tracker?.destroy();
|
|
2187
|
+
});
|
|
2188
|
+
tracker = new Transform({
|
|
2189
|
+
transform(chunk, encoding, callback) {
|
|
2190
|
+
transferred += Buffer.byteLength(chunk);
|
|
2191
|
+
watchdog.activity();
|
|
2192
|
+
progress.updatePart(part.partNumber, transferred);
|
|
2193
|
+
callback(null, chunk);
|
|
2194
|
+
},
|
|
2195
|
+
});
|
|
2196
|
+
try {
|
|
2197
|
+
source = createReadStream(filePath, { start, end: start + size - 1 });
|
|
2198
|
+
const response = await fetch(part.url, {
|
|
2199
|
+
method: 'PUT',
|
|
2200
|
+
headers: { 'Content-Length': String(size) },
|
|
2201
|
+
body: source.pipe(tracker),
|
|
2202
|
+
duplex: 'half',
|
|
2203
|
+
signal: controller.signal,
|
|
2204
|
+
});
|
|
2205
|
+
if (!response.ok) {
|
|
2206
|
+
const text = await response.text().catch(() => '');
|
|
2207
|
+
throw new Error(`Part ${part.partNumber} failed: ${response.status} ${redact(text || response.statusText)}`);
|
|
2208
|
+
}
|
|
2209
|
+
progress.completePart(part.partNumber, size);
|
|
2210
|
+
return;
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
progress.resetPart(part.partNumber);
|
|
2213
|
+
const reason = controller.signal.aborted && controller.signal.reason instanceof Error
|
|
2214
|
+
? controller.signal.reason
|
|
2215
|
+
: error;
|
|
2216
|
+
if (attempt === VIDEO_UPLOAD_PART_ATTEMPTS - 1) throw reason;
|
|
2217
|
+
await sleep(VIDEO_UPLOAD_RETRY_BASE_MS * (2 ** attempt));
|
|
2218
|
+
} finally {
|
|
2219
|
+
watchdog.stop();
|
|
2220
|
+
source?.destroy();
|
|
2221
|
+
tracker?.destroy();
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
async function uploadVideoMultipart(config, options, resolved, initialized, totalBytes) {
|
|
2227
|
+
const status = await apiFetch(
|
|
2228
|
+
config,
|
|
2229
|
+
options,
|
|
2230
|
+
'GET',
|
|
2231
|
+
`/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}`,
|
|
2232
|
+
);
|
|
2233
|
+
const uploadedParts = Array.isArray(status.uploadedParts) ? status.uploadedParts : [];
|
|
2234
|
+
const uploadedNumbers = new Set(uploadedParts.map((part) => Number(part.partNumber)));
|
|
2235
|
+
const missing = Array.from({ length: Number(initialized.partCount) }, (_, index) => index + 1)
|
|
2236
|
+
.filter((partNumber) => !uploadedNumbers.has(partNumber));
|
|
2237
|
+
const progress = createMultipartUploadProgress(options, totalBytes, uploadedParts);
|
|
2238
|
+
|
|
2239
|
+
try {
|
|
2240
|
+
for (let offset = 0; offset < missing.length; offset += VIDEO_UPLOAD_PART_SIGN_BATCH) {
|
|
2241
|
+
const partNumbers = missing.slice(offset, offset + VIDEO_UPLOAD_PART_SIGN_BATCH);
|
|
2242
|
+
const signed = await apiFetch(
|
|
2243
|
+
config,
|
|
2244
|
+
options,
|
|
2245
|
+
'POST',
|
|
2246
|
+
`/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}/parts`,
|
|
2247
|
+
{ partNumbers },
|
|
2248
|
+
);
|
|
2249
|
+
const queue = [...(signed.signedPartUrls || [])];
|
|
2250
|
+
const signedNumbers = new Set(queue.map((part) => Number(part?.partNumber)));
|
|
2251
|
+
if (
|
|
2252
|
+
queue.length !== partNumbers.length
|
|
2253
|
+
|| signedNumbers.size !== partNumbers.length
|
|
2254
|
+
|| partNumbers.some((partNumber) => !signedNumbers.has(partNumber))
|
|
2255
|
+
|| queue.some((part) => typeof part?.url !== 'string' || !part.url)
|
|
2256
|
+
) {
|
|
2257
|
+
throw new Error('Server returned an incomplete multipart signing batch.');
|
|
2258
|
+
}
|
|
2259
|
+
let cursor = 0;
|
|
2260
|
+
const worker = async () => {
|
|
2261
|
+
while (cursor < queue.length) {
|
|
2262
|
+
const part = queue[cursor++];
|
|
2263
|
+
const start = (part.partNumber - 1) * initialized.partSizeBytes;
|
|
2264
|
+
const size = Math.min(initialized.partSizeBytes, totalBytes - start);
|
|
2265
|
+
if (size !== Number(part.expectedSizeBytes)) {
|
|
2266
|
+
throw new Error(`Server returned an invalid size for upload part ${part.partNumber}.`);
|
|
2267
|
+
}
|
|
2268
|
+
await putSignedVideoPart(part, resolved, start, size, progress);
|
|
2269
|
+
}
|
|
2270
|
+
};
|
|
2271
|
+
const workerCount = Math.min(VIDEO_UPLOAD_PART_CONCURRENCY, queue.length);
|
|
2272
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
2273
|
+
}
|
|
2274
|
+
} finally {
|
|
2275
|
+
progress.finish();
|
|
1807
2276
|
}
|
|
1808
|
-
yield Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
1809
2277
|
}
|
|
1810
2278
|
|
|
1811
2279
|
async function uploadVideo(config, options, filePath) {
|
|
@@ -1815,26 +2283,147 @@ async function uploadVideo(config, options, filePath) {
|
|
|
1815
2283
|
if (!stat.isFile()) {
|
|
1816
2284
|
throw Object.assign(new Error(`Upload path is not a file: ${resolved}`), { exitCode: EXIT.USAGE });
|
|
1817
2285
|
}
|
|
1818
|
-
|
|
1819
|
-
const filename = options.filename || path.basename(resolved);
|
|
2286
|
+
const filename = String(options.filename || path.basename(resolved));
|
|
1820
2287
|
const contentType = mimeForPath(resolved);
|
|
1821
|
-
const
|
|
1822
|
-
|
|
1823
|
-
|
|
2288
|
+
const title = options.title === undefined || options.title === null
|
|
2289
|
+
? null
|
|
2290
|
+
: String(options.title);
|
|
2291
|
+
const idempotencyKey = String(
|
|
2292
|
+
options['idempotency-key']
|
|
2293
|
+
|| defaultVideoUploadIdempotencyKey(resolved, stat, filename, contentType),
|
|
1824
2294
|
);
|
|
1825
|
-
const
|
|
1826
|
-
|
|
1827
|
-
|
|
2295
|
+
const uploadIdentity = {
|
|
2296
|
+
filePath: resolved,
|
|
2297
|
+
filename,
|
|
2298
|
+
title,
|
|
2299
|
+
profile: profileName(config, options),
|
|
2300
|
+
baseUrl: getBaseUrl(config, options),
|
|
2301
|
+
allowCustomHost: allowCustomHost(options),
|
|
2302
|
+
contentType,
|
|
2303
|
+
size: stat.size,
|
|
2304
|
+
mtimeMs: stat.mtimeMs,
|
|
2305
|
+
};
|
|
2306
|
+
const resumes = await readVideoUploadResumes();
|
|
2307
|
+
const existingResume = resumes[idempotencyKey];
|
|
2308
|
+
if (existingResume && typeof existingResume === 'object') {
|
|
2309
|
+
const mismatchedFields = Object.entries(uploadIdentity)
|
|
2310
|
+
.filter(([key, value]) => {
|
|
2311
|
+
const existingValue = existingResume[key];
|
|
2312
|
+
if (key === 'title') return (existingValue ?? null) !== value;
|
|
2313
|
+
return existingValue !== value;
|
|
2314
|
+
})
|
|
2315
|
+
.map(([key]) => key);
|
|
2316
|
+
if (mismatchedFields.length > 0) {
|
|
2317
|
+
const abortArgs = existingResume.intentId
|
|
2318
|
+
? ['videos', 'abort-upload', String(existingResume.intentId), '--profile', String(existingResume.profile || uploadIdentity.profile), '--base-url', String(existingResume.baseUrl || uploadIdentity.baseUrl), '--confirm']
|
|
2319
|
+
: null;
|
|
2320
|
+
if (abortArgs && existingResume.allowCustomHost === true) abortArgs.push('--allow-custom-host');
|
|
2321
|
+
throw Object.assign(
|
|
2322
|
+
new Error('The file or upload identity changed since this resumable upload began. Abort the prior upload and retry with a new idempotency key.'),
|
|
2323
|
+
{
|
|
2324
|
+
exitCode: EXIT.USAGE,
|
|
2325
|
+
data: {
|
|
2326
|
+
idempotencyKey,
|
|
2327
|
+
uploadIntentId: existingResume.intentId,
|
|
2328
|
+
mismatchedFields,
|
|
2329
|
+
abortArgs,
|
|
2330
|
+
abortCommand: abortArgs ? `clipit ${abortArgs.map(shellQuote).join(' ')}` : undefined,
|
|
2331
|
+
},
|
|
2332
|
+
},
|
|
2333
|
+
);
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
1828
2336
|
await enforceMaxCredits(config, options, 'videos upload', { bytes: stat.size });
|
|
1829
2337
|
confirmPaid(options, 'Uploading a video');
|
|
2338
|
+
await saveVideoUploadResume(idempotencyKey, {
|
|
2339
|
+
...uploadIdentity,
|
|
2340
|
+
intentId: existingResume?.intentId,
|
|
2341
|
+
jobId: existingResume?.jobId,
|
|
2342
|
+
updatedAt: new Date().toISOString(),
|
|
2343
|
+
});
|
|
2344
|
+
|
|
2345
|
+
let initialized;
|
|
1830
2346
|
try {
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2347
|
+
initialized = await apiFetch(config, options, 'POST', '/api/v1/videos/uploads', {
|
|
2348
|
+
filename,
|
|
2349
|
+
contentType,
|
|
2350
|
+
size: stat.size,
|
|
2351
|
+
title: title ?? undefined,
|
|
2352
|
+
idempotencyKey,
|
|
1837
2353
|
});
|
|
2354
|
+
if (!initialized?.intentId || !initialized?.jobId) {
|
|
2355
|
+
throw Object.assign(new Error('Video upload initialization returned an unexpected response.'), {
|
|
2356
|
+
exitCode: EXIT.SERVER,
|
|
2357
|
+
data: initialized,
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
if (!initialized.readyForUpload) {
|
|
2361
|
+
await removeVideoUploadResume(idempotencyKey);
|
|
2362
|
+
if (initialized.videoId) {
|
|
2363
|
+
await persistActiveContext(
|
|
2364
|
+
config,
|
|
2365
|
+
options,
|
|
2366
|
+
{ videoId: initialized.videoId },
|
|
2367
|
+
[{ type: 'video', id: initialized.videoId }],
|
|
2368
|
+
);
|
|
2369
|
+
}
|
|
2370
|
+
output(initialized, options);
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
await saveVideoUploadResume(idempotencyKey, {
|
|
2375
|
+
...uploadIdentity,
|
|
2376
|
+
intentId: initialized.intentId,
|
|
2377
|
+
jobId: initialized.jobId,
|
|
2378
|
+
updatedAt: new Date().toISOString(),
|
|
2379
|
+
});
|
|
2380
|
+
|
|
2381
|
+
if (initialized.transport === 'single') {
|
|
2382
|
+
if (!initialized.uploadUrl || Number(initialized.expectedSizeBytes) !== stat.size) {
|
|
2383
|
+
throw new Error('Direct upload initialization returned invalid transfer metadata.');
|
|
2384
|
+
}
|
|
2385
|
+
await putSignedUpload(
|
|
2386
|
+
initialized.uploadUrl,
|
|
2387
|
+
resolved,
|
|
2388
|
+
contentType,
|
|
2389
|
+
stat.size,
|
|
2390
|
+
options,
|
|
2391
|
+
initialized.requiredHeaders,
|
|
2392
|
+
);
|
|
2393
|
+
} else if (initialized.transport === 'multipart') {
|
|
2394
|
+
if (
|
|
2395
|
+
!Number.isInteger(initialized.partCount)
|
|
2396
|
+
|| !Number.isInteger(initialized.partSizeBytes)
|
|
2397
|
+
|| Number(initialized.expectedSizeBytes) !== stat.size
|
|
2398
|
+
) {
|
|
2399
|
+
throw new Error('Multipart upload initialization returned invalid transfer metadata.');
|
|
2400
|
+
}
|
|
2401
|
+
await uploadVideoMultipart(config, options, resolved, initialized, stat.size);
|
|
2402
|
+
} else {
|
|
2403
|
+
throw new Error('Video upload initialization returned no supported transport.');
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
const completedStat = await fs.stat(resolved);
|
|
2407
|
+
if (
|
|
2408
|
+
!completedStat.isFile()
|
|
2409
|
+
|| completedStat.size !== stat.size
|
|
2410
|
+
|| completedStat.mtimeMs !== stat.mtimeMs
|
|
2411
|
+
|| completedStat.ctimeMs !== stat.ctimeMs
|
|
2412
|
+
) {
|
|
2413
|
+
throw Object.assign(
|
|
2414
|
+
new Error('The video file changed while it was uploading. Abort this upload and retry with a new idempotency key.'),
|
|
2415
|
+
{ exitCode: EXIT.USAGE, data: { fileChangedDuringUpload: true } },
|
|
2416
|
+
);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
const result = await apiFetch(
|
|
2420
|
+
config,
|
|
2421
|
+
options,
|
|
2422
|
+
'POST',
|
|
2423
|
+
`/api/v1/videos/uploads/${encodeURIComponent(initialized.intentId)}/complete`,
|
|
2424
|
+
{},
|
|
2425
|
+
);
|
|
2426
|
+
await removeVideoUploadResume(idempotencyKey);
|
|
1838
2427
|
if (result?.videoId) {
|
|
1839
2428
|
await persistActiveContext(
|
|
1840
2429
|
config,
|
|
@@ -1844,8 +2433,42 @@ async function uploadVideo(config, options, filePath) {
|
|
|
1844
2433
|
);
|
|
1845
2434
|
}
|
|
1846
2435
|
output(result, options);
|
|
1847
|
-
}
|
|
1848
|
-
|
|
2436
|
+
} catch (error) {
|
|
2437
|
+
if (error && typeof error === 'object') {
|
|
2438
|
+
const requiresNewIdempotencyKey = error.data?.requiresNewIdempotencyKey === true
|
|
2439
|
+
|| error.data?.code === 'IDEMPOTENCY_KEY_TERMINAL';
|
|
2440
|
+
if (requiresNewIdempotencyKey) {
|
|
2441
|
+
await removeVideoUploadResume(idempotencyKey);
|
|
2442
|
+
error.data = {
|
|
2443
|
+
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
|
2444
|
+
uploadIntentId: initialized?.intentId ?? existingResume?.intentId,
|
|
2445
|
+
idempotencyKey,
|
|
2446
|
+
};
|
|
2447
|
+
throw error;
|
|
2448
|
+
}
|
|
2449
|
+
const resumeArgs = [
|
|
2450
|
+
'videos',
|
|
2451
|
+
'upload',
|
|
2452
|
+
resolved,
|
|
2453
|
+
'--idempotency-key',
|
|
2454
|
+
idempotencyKey,
|
|
2455
|
+
];
|
|
2456
|
+
resumeArgs.push('--filename', filename);
|
|
2457
|
+
if (title) resumeArgs.push('--title', title);
|
|
2458
|
+
resumeArgs.push('--profile', uploadIdentity.profile);
|
|
2459
|
+
resumeArgs.push('--base-url', uploadIdentity.baseUrl);
|
|
2460
|
+
if (uploadIdentity.allowCustomHost) resumeArgs.push('--allow-custom-host');
|
|
2461
|
+
if (boolOption(options.confirm) || boolOption(options.yes)) resumeArgs.push('--confirm');
|
|
2462
|
+
if (wantJson(options)) resumeArgs.push('--json');
|
|
2463
|
+
error.data = {
|
|
2464
|
+
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
|
2465
|
+
uploadIntentId: initialized?.intentId ?? existingResume?.intentId,
|
|
2466
|
+
idempotencyKey,
|
|
2467
|
+
resumeArgs,
|
|
2468
|
+
resumeCommand: `clipit ${resumeArgs.map(shellQuote).join(' ')}`,
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
throw error;
|
|
1849
2472
|
}
|
|
1850
2473
|
}
|
|
1851
2474
|
|
|
@@ -1866,13 +2489,30 @@ async function videos(config, options, action, args) {
|
|
|
1866
2489
|
if (!url) throw Object.assign(new Error('URL is required.'), { exitCode: EXIT.USAGE });
|
|
1867
2490
|
await enforceMaxCredits(config, options, 'videos import-url', { url });
|
|
1868
2491
|
confirmPaid(options, 'Importing a video from URL');
|
|
1869
|
-
output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', {
|
|
2492
|
+
output(await apiFetch(config, options, 'POST', '/api/v1/videos/from-url', {
|
|
2493
|
+
url,
|
|
2494
|
+
title: options.title,
|
|
2495
|
+
idempotencyKey: options['idempotency-key'] || randomUUID(),
|
|
2496
|
+
}), options);
|
|
1870
2497
|
return;
|
|
1871
2498
|
}
|
|
1872
2499
|
if (action === 'upload') {
|
|
1873
2500
|
await uploadVideo(config, options, args[0] || options.file);
|
|
1874
2501
|
return;
|
|
1875
2502
|
}
|
|
2503
|
+
if (action === 'abort-upload') {
|
|
2504
|
+
const intentId = requiredString(args[0] || options['intent-id'], 'Upload intent id');
|
|
2505
|
+
requireConfirm(options, 'Aborting a video upload');
|
|
2506
|
+
const result = await apiFetch(
|
|
2507
|
+
config,
|
|
2508
|
+
options,
|
|
2509
|
+
'DELETE',
|
|
2510
|
+
`/api/v1/videos/uploads/${encodeURIComponent(intentId)}`,
|
|
2511
|
+
);
|
|
2512
|
+
await removeVideoUploadResumeByIntent(intentId);
|
|
2513
|
+
output(result, options);
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
1876
2516
|
if (action === 'transcribe') {
|
|
1877
2517
|
if (!args[0]) throw Object.assign(new Error('Video id is required.'), { exitCode: EXIT.USAGE });
|
|
1878
2518
|
await enforceMaxCredits(config, options, 'videos transcribe', { videoId: args[0] });
|
|
@@ -1911,6 +2551,119 @@ async function videos(config, options, action, args) {
|
|
|
1911
2551
|
throw Object.assign(new Error(`Unknown videos command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
1912
2552
|
}
|
|
1913
2553
|
|
|
2554
|
+
function formatClipDeliveryState(state) {
|
|
2555
|
+
const editor = state?.editorState;
|
|
2556
|
+
const selected = state?.selectedExport;
|
|
2557
|
+
const candidates = Array.isArray(state?.exports) ? state.exports : [];
|
|
2558
|
+
const blockers = Array.isArray(state?.deliveryBlockers) ? state.deliveryBlockers : [];
|
|
2559
|
+
const lines = [
|
|
2560
|
+
`Clip: ${state?.clipId || 'unknown'}`,
|
|
2561
|
+
editor
|
|
2562
|
+
? `Editor snapshot: ${editor.snapshotId}`
|
|
2563
|
+
: `Editor: ${state?.editorStateStatus || 'unavailable'}`,
|
|
2564
|
+
editor ? `Editor version: ${editor.editorVersion}` : null,
|
|
2565
|
+
editor ? `Editor state hash: ${editor.editorStateHash}` : null,
|
|
2566
|
+
editor ? `Source object fingerprint: ${editor.sourceObjectFingerprint || 'unavailable'}` : null,
|
|
2567
|
+
editor ? `Saved: ${editor.saveOrigin} at ${editor.savedAt}` : null,
|
|
2568
|
+
editor
|
|
2569
|
+
? `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'}`
|
|
2570
|
+
: null,
|
|
2571
|
+
`Selection: ${state?.selection?.status || 'unknown'}${state?.selection?.selectedExportId ? ` (${state.selection.selectedExportId})` : ''}`,
|
|
2572
|
+
selected
|
|
2573
|
+
? `Selected export: ${selected.exportId}; snapshot ${selected.snapshotId || 'unavailable'}; exact current match ${selected.exactlyMatchesEditor === true ? 'yes' : 'no'}`
|
|
2574
|
+
: `Artifacts: ${candidates.length}`,
|
|
2575
|
+
selected ? `Output object fingerprint: ${selected.outputObjectFingerprint || 'unavailable'}` : null,
|
|
2576
|
+
selected ? `Storage item: ${selected.storageItemId || 'unavailable'}` : null,
|
|
2577
|
+
selected
|
|
2578
|
+
? `Artifact: ${selected.width || '?'}x${selected.height || '?'} ${selected.duration || '?'}s; audio ${selected.hasAudio === true ? 'yes' : selected.hasAudio === false ? 'no' : 'unknown'}; probe ${selected.inspectionStatus}`
|
|
2579
|
+
: null,
|
|
2580
|
+
`Ready to publish: ${state?.readyToPublish === true ? 'yes' : 'no'}`,
|
|
2581
|
+
].filter(Boolean);
|
|
2582
|
+
if (candidates.length > 1) {
|
|
2583
|
+
lines.push(`Export ids: ${candidates.map((candidate) => candidate.exportId).join(', ')}`);
|
|
2584
|
+
}
|
|
2585
|
+
if (blockers.length) {
|
|
2586
|
+
lines.push('Blockers:');
|
|
2587
|
+
lines.push(...blockers.map((blocker) => `- ${blocker}`));
|
|
2588
|
+
}
|
|
2589
|
+
return lines.join('\n');
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function requireVerifiedEditorState(deliveryState, clipId) {
|
|
2593
|
+
const editor = deliveryState?.editorState;
|
|
2594
|
+
if (
|
|
2595
|
+
deliveryState?.schema !== 'clipit_clip_delivery_state'
|
|
2596
|
+
|| deliveryState?.version !== 2
|
|
2597
|
+
|| deliveryState?.clipId !== clipId
|
|
2598
|
+
|| deliveryState?.editorStateStatus !== 'verified'
|
|
2599
|
+
|| !editor
|
|
2600
|
+
|| typeof editor.snapshotId !== 'string'
|
|
2601
|
+
|| !editor.snapshotId
|
|
2602
|
+
|| !Number.isInteger(editor.editorVersion)
|
|
2603
|
+
|| editor.editorVersion < 1
|
|
2604
|
+
|| typeof editor.editorStateHash !== 'string'
|
|
2605
|
+
|| !/^[a-f0-9]{64}$/i.test(editor.editorStateHash)
|
|
2606
|
+
|| editor.stateSource !== 'current_editor_snapshot'
|
|
2607
|
+
) {
|
|
2608
|
+
throw Object.assign(
|
|
2609
|
+
new Error('Clip does not have a verified canonical editor snapshot. Save and verify the editor state before continuing.'),
|
|
2610
|
+
{ exitCode: EXIT.SERVER, data: deliveryState },
|
|
2611
|
+
);
|
|
2612
|
+
}
|
|
2613
|
+
return editor;
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
function requireExactCurrentExport(deliveryState, editorState, options = {}) {
|
|
2617
|
+
const requestedExportId = options.requestedExportId
|
|
2618
|
+
? String(options.requestedExportId)
|
|
2619
|
+
: null;
|
|
2620
|
+
const selection = deliveryState?.selection;
|
|
2621
|
+
const selected = deliveryState?.selectedExport;
|
|
2622
|
+
const blockers = Array.isArray(deliveryState?.deliveryBlockers)
|
|
2623
|
+
? deliveryState.deliveryBlockers
|
|
2624
|
+
: [];
|
|
2625
|
+
const selectedBlockers = Array.isArray(selected?.blockers) ? selected.blockers : [];
|
|
2626
|
+
const responseRequestedExportId = selection?.requestedExportId ?? null;
|
|
2627
|
+
const exact = Boolean(
|
|
2628
|
+
selection?.status === 'selected'
|
|
2629
|
+
&& selected
|
|
2630
|
+
&& typeof selected.exportId === 'string'
|
|
2631
|
+
&& selected.exportId
|
|
2632
|
+
&& selection.selectedExportId === selected.exportId
|
|
2633
|
+
&& responseRequestedExportId === requestedExportId
|
|
2634
|
+
&& (!requestedExportId || selected.exportId === requestedExportId)
|
|
2635
|
+
&& selected.snapshotId === editorState.snapshotId
|
|
2636
|
+
&& selected.editorVersion === editorState.editorVersion
|
|
2637
|
+
&& selected.editorStateHash === editorState.editorStateHash
|
|
2638
|
+
&& selected.exactlyMatchesEditor === true
|
|
2639
|
+
&& selected.inspectionStatus === 'verified'
|
|
2640
|
+
&& typeof selected.outputObjectFingerprint === 'string'
|
|
2641
|
+
&& /^[a-f0-9]{64}$/i.test(selected.outputObjectFingerprint)
|
|
2642
|
+
&& selectedBlockers.length === 0
|
|
2643
|
+
);
|
|
2644
|
+
const publishReady = !options.requireReadyToPublish
|
|
2645
|
+
|| (deliveryState?.readyToPublish === true && blockers.length === 0);
|
|
2646
|
+
if (!exact || !publishReady) {
|
|
2647
|
+
const detail = blockers.length
|
|
2648
|
+
? blockers.join(' ')
|
|
2649
|
+
: `Selection status is ${selection?.status || 'unknown'}.`;
|
|
2650
|
+
throw Object.assign(
|
|
2651
|
+
new Error(`Clip does not have one verified exact-current export: ${detail}`),
|
|
2652
|
+
{ exitCode: EXIT.SERVER, data: deliveryState },
|
|
2653
|
+
);
|
|
2654
|
+
}
|
|
2655
|
+
return selected;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
async function fetchCanonicalClipDeliveryState(config, options, clipId, exportId) {
|
|
2659
|
+
return apiFetch(
|
|
2660
|
+
config,
|
|
2661
|
+
options,
|
|
2662
|
+
'GET',
|
|
2663
|
+
`/api/v1/clips/${encodeURIComponent(clipId)}/delivery-state${queryString({ exportId })}`,
|
|
2664
|
+
);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
1914
2667
|
async function clips(config, options, action, args) {
|
|
1915
2668
|
if (action === 'list') {
|
|
1916
2669
|
output(await apiFetch(config, options, 'GET', `/api/v1/clips${queryString({
|
|
@@ -1927,6 +2680,19 @@ async function clips(config, options, action, args) {
|
|
|
1927
2680
|
output(result, options);
|
|
1928
2681
|
return;
|
|
1929
2682
|
}
|
|
2683
|
+
if (action === 'delivery-state') {
|
|
2684
|
+
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
2685
|
+
const result = await apiFetch(
|
|
2686
|
+
config,
|
|
2687
|
+
options,
|
|
2688
|
+
'GET',
|
|
2689
|
+
`/api/v1/clips/${encodeURIComponent(args[0])}/delivery-state${queryString({
|
|
2690
|
+
exportId: options['export-id'],
|
|
2691
|
+
})}`,
|
|
2692
|
+
);
|
|
2693
|
+
output(wantJson(options) ? result : formatClipDeliveryState(result), options);
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
1930
2696
|
if (action === 'create') {
|
|
1931
2697
|
const body = options.params
|
|
1932
2698
|
? await readJsonOption(String(options.params))
|
|
@@ -1959,6 +2725,12 @@ async function clips(config, options, action, args) {
|
|
|
1959
2725
|
}
|
|
1960
2726
|
if (action === 'update') {
|
|
1961
2727
|
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
2728
|
+
if (options.aspect !== undefined || options['aspect-ratio'] !== undefined) {
|
|
2729
|
+
throw Object.assign(
|
|
2730
|
+
new Error('clips update does not accept --aspect-ratio. Use clipit run setClipAspectRatio --clip-id <id> --params \'{"aspectRatio":"4:5"}\', then start an explicit render.'),
|
|
2731
|
+
{ exitCode: EXIT.USAGE },
|
|
2732
|
+
);
|
|
2733
|
+
}
|
|
1962
2734
|
const body = options.params
|
|
1963
2735
|
? await readJsonOption(String(options.params))
|
|
1964
2736
|
: {
|
|
@@ -1973,6 +2745,26 @@ async function clips(config, options, action, args) {
|
|
|
1973
2745
|
output(await apiFetch(config, options, 'PATCH', `/api/v1/clips/${encodeURIComponent(args[0])}`, body), options);
|
|
1974
2746
|
return;
|
|
1975
2747
|
}
|
|
2748
|
+
if (action === 'initialize-snapshot') {
|
|
2749
|
+
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
2750
|
+
const body = options.params
|
|
2751
|
+
? await readJsonOption(String(options.params))
|
|
2752
|
+
: compactObject({
|
|
2753
|
+
aspectRatio: options.aspect || options['aspect-ratio'],
|
|
2754
|
+
fitBackground: options['fit-background'],
|
|
2755
|
+
quality: options.quality,
|
|
2756
|
+
includeCaptions: options.captions === undefined ? undefined : boolOption(options.captions),
|
|
2757
|
+
captionStyle: options['caption-style'],
|
|
2758
|
+
});
|
|
2759
|
+
output(await apiFetch(
|
|
2760
|
+
config,
|
|
2761
|
+
options,
|
|
2762
|
+
'POST',
|
|
2763
|
+
`/api/v1/clips/${encodeURIComponent(args[0])}/editor-snapshot/initialize`,
|
|
2764
|
+
body,
|
|
2765
|
+
), options);
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
1976
2768
|
if (action === 'render') {
|
|
1977
2769
|
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
1978
2770
|
const body = options.params
|
|
@@ -1994,9 +2786,34 @@ async function clips(config, options, action, args) {
|
|
|
1994
2786
|
}
|
|
1995
2787
|
if (action === 'download') {
|
|
1996
2788
|
if (!args[0]) throw Object.assign(new Error('Clip id is required.'), { exitCode: EXIT.USAGE });
|
|
1997
|
-
const
|
|
2789
|
+
const clipId = args[0];
|
|
2790
|
+
const requestedExportId = options['export-id'];
|
|
2791
|
+
const deliveryState = await fetchCanonicalClipDeliveryState(
|
|
2792
|
+
config,
|
|
2793
|
+
options,
|
|
2794
|
+
clipId,
|
|
2795
|
+
requestedExportId,
|
|
2796
|
+
);
|
|
2797
|
+
const editorState = requireVerifiedEditorState(deliveryState, clipId);
|
|
2798
|
+
const selectedExport = requireExactCurrentExport(deliveryState, editorState, {
|
|
2799
|
+
requestedExportId,
|
|
2800
|
+
});
|
|
2801
|
+
const result = await apiFetch(
|
|
2802
|
+
config,
|
|
2803
|
+
options,
|
|
2804
|
+
'GET',
|
|
2805
|
+
`/api/v1/exports/${encodeURIComponent(selectedExport.exportId)}/download`,
|
|
2806
|
+
);
|
|
1998
2807
|
if (options.open && result?.downloadUrl) openBrowser(result.downloadUrl);
|
|
1999
|
-
output(
|
|
2808
|
+
output({
|
|
2809
|
+
...result,
|
|
2810
|
+
clipId,
|
|
2811
|
+
exportId: selectedExport.exportId,
|
|
2812
|
+
snapshotId: editorState.snapshotId,
|
|
2813
|
+
editorVersion: editorState.editorVersion,
|
|
2814
|
+
editorStateHash: editorState.editorStateHash,
|
|
2815
|
+
outputObjectFingerprint: selectedExport.outputObjectFingerprint,
|
|
2816
|
+
}, options);
|
|
2000
2817
|
return;
|
|
2001
2818
|
}
|
|
2002
2819
|
if (action === 'delete') {
|
|
@@ -2139,6 +2956,17 @@ function defaultExportStartBody(clipId) {
|
|
|
2139
2956
|
};
|
|
2140
2957
|
}
|
|
2141
2958
|
|
|
2959
|
+
function buildCanonicalExportStartBody(clipId, params, editorState, idempotencyKey) {
|
|
2960
|
+
return {
|
|
2961
|
+
...defaultExportStartBody(clipId),
|
|
2962
|
+
...params,
|
|
2963
|
+
clipId,
|
|
2964
|
+
idempotencyKey,
|
|
2965
|
+
expectedEditorVersion: editorState.editorVersion,
|
|
2966
|
+
expectedEditorStateHash: editorState.editorStateHash,
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2142
2970
|
async function pollExport(config, options, jobId) {
|
|
2143
2971
|
const startedAt = Date.now();
|
|
2144
2972
|
const timeoutMs = numberOption(options['timeout-ms'], '--timeout-ms');
|
|
@@ -2161,14 +2989,32 @@ async function exportsCommand(config, options, action, args) {
|
|
|
2161
2989
|
if (action === 'start') {
|
|
2162
2990
|
const clipId = requiredString(options['clip-id'], '--clip-id');
|
|
2163
2991
|
const params = options.params ? await readJsonOption(String(options.params)) : {};
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2992
|
+
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
|
2993
|
+
throw Object.assign(new Error('--params must contain a JSON object.'), { exitCode: EXIT.USAGE });
|
|
2994
|
+
}
|
|
2995
|
+
const deliveryState = await fetchCanonicalClipDeliveryState(config, options, clipId);
|
|
2996
|
+
const editorState = requireVerifiedEditorState(deliveryState, clipId);
|
|
2997
|
+
const idempotencyKey = requiredString(
|
|
2998
|
+
options['idempotency-key'] || params.idempotencyKey || `cli-export:${randomUUID()}`,
|
|
2999
|
+
'--idempotency-key',
|
|
3000
|
+
);
|
|
3001
|
+
const body = buildCanonicalExportStartBody(
|
|
2167
3002
|
clipId,
|
|
2168
|
-
|
|
3003
|
+
params,
|
|
3004
|
+
editorState,
|
|
3005
|
+
idempotencyKey,
|
|
3006
|
+
);
|
|
2169
3007
|
await enforceMaxCredits(config, options, 'exports start', { clipId, body });
|
|
2170
3008
|
confirmPaid(options, 'Starting an export');
|
|
2171
|
-
|
|
3009
|
+
const result = await apiFetch(config, options, 'POST', '/api/v1/exports', body);
|
|
3010
|
+
output({
|
|
3011
|
+
...result,
|
|
3012
|
+
clipId,
|
|
3013
|
+
snapshotId: editorState.snapshotId,
|
|
3014
|
+
expectedEditorVersion: body.expectedEditorVersion,
|
|
3015
|
+
expectedEditorStateHash: body.expectedEditorStateHash,
|
|
3016
|
+
idempotencyKey: body.idempotencyKey,
|
|
3017
|
+
}, options);
|
|
2172
3018
|
return;
|
|
2173
3019
|
}
|
|
2174
3020
|
if (action === 'list') {
|
|
@@ -2200,32 +3046,76 @@ async function exportsCommand(config, options, action, args) {
|
|
|
2200
3046
|
throw Object.assign(new Error(`Unknown exports command: ${action || ''}`), { exitCode: EXIT.USAGE });
|
|
2201
3047
|
}
|
|
2202
3048
|
|
|
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: {
|
|
3049
|
+
async function putSignedUpload(uploadUrl, filePath, contentType, size, options, requiredHeaders) {
|
|
3050
|
+
const headers = requiredHeaders && typeof requiredHeaders === 'object'
|
|
3051
|
+
? Object.fromEntries(Object.entries(requiredHeaders).map(([key, value]) => [key, String(value)]))
|
|
3052
|
+
: {
|
|
2215
3053
|
'Content-Type': contentType,
|
|
2216
3054
|
'Content-Length': String(size),
|
|
2217
|
-
}
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
3055
|
+
};
|
|
3056
|
+
const requiredContentType = headers['Content-Type'] ?? headers['content-type'];
|
|
3057
|
+
const requiredContentLength = headers['Content-Length'] ?? headers['content-length'];
|
|
3058
|
+
if (requiredContentType !== contentType || requiredContentLength !== String(size)) {
|
|
3059
|
+
throw Object.assign(
|
|
3060
|
+
new Error('Upload signing response did not preserve the requested Content-Type and Content-Length headers.'),
|
|
3061
|
+
{ exitCode: EXIT.SERVER },
|
|
3062
|
+
);
|
|
2225
3063
|
}
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
3064
|
+
|
|
3065
|
+
for (let attempt = 0; attempt < VIDEO_UPLOAD_PART_ATTEMPTS; attempt += 1) {
|
|
3066
|
+
const progress = createUploadProgress(options, size);
|
|
3067
|
+
const controller = new AbortController();
|
|
3068
|
+
let source;
|
|
3069
|
+
let tracker;
|
|
3070
|
+
const watchdog = createUploadActivityWatchdog(controller, () => {
|
|
3071
|
+
source?.destroy();
|
|
3072
|
+
tracker?.destroy();
|
|
3073
|
+
});
|
|
3074
|
+
try {
|
|
3075
|
+
tracker = new Transform({
|
|
3076
|
+
transform(chunk, encoding, callback) {
|
|
3077
|
+
watchdog.activity();
|
|
3078
|
+
progress.track(chunk);
|
|
3079
|
+
callback(null, chunk);
|
|
3080
|
+
},
|
|
3081
|
+
});
|
|
3082
|
+
source = createReadStream(filePath);
|
|
3083
|
+
const response = await fetch(uploadUrl, {
|
|
3084
|
+
method: 'PUT',
|
|
3085
|
+
headers,
|
|
3086
|
+
body: source.pipe(tracker),
|
|
3087
|
+
duplex: 'half',
|
|
3088
|
+
signal: controller.signal,
|
|
3089
|
+
});
|
|
3090
|
+
if (!response.ok) {
|
|
3091
|
+
const text = await response.text().catch(() => '');
|
|
3092
|
+
throw Object.assign(
|
|
3093
|
+
new Error(`Upload failed: ${response.status} ${redact(text || response.statusText)}`),
|
|
3094
|
+
{ exitCode: EXIT.SERVER, status: response.status },
|
|
3095
|
+
);
|
|
3096
|
+
}
|
|
3097
|
+
return;
|
|
3098
|
+
} catch (error) {
|
|
3099
|
+
const reason = controller.signal.aborted && controller.signal.reason instanceof Error
|
|
3100
|
+
? controller.signal.reason
|
|
3101
|
+
: error;
|
|
3102
|
+
const retryable = reason?.status === undefined
|
|
3103
|
+
|| reason.status === 408
|
|
3104
|
+
|| reason.status === 429
|
|
3105
|
+
|| reason.status >= 500;
|
|
3106
|
+
if (attempt === VIDEO_UPLOAD_PART_ATTEMPTS - 1 || !retryable) {
|
|
3107
|
+
throw Object.assign(new Error(`Upload failed: ${reason.message}`), {
|
|
3108
|
+
exitCode: reason.exitCode || EXIT.NETWORK,
|
|
3109
|
+
status: reason.status,
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
await sleep(VIDEO_UPLOAD_RETRY_BASE_MS * (2 ** attempt));
|
|
3113
|
+
} finally {
|
|
3114
|
+
watchdog.stop();
|
|
3115
|
+
source?.destroy();
|
|
3116
|
+
tracker?.destroy();
|
|
3117
|
+
progress.finish();
|
|
3118
|
+
}
|
|
2229
3119
|
}
|
|
2230
3120
|
}
|
|
2231
3121
|
|
|
@@ -2242,28 +3132,35 @@ async function uploadAsset(config, options, filePath) {
|
|
|
2242
3132
|
contentType,
|
|
2243
3133
|
size: stat.size,
|
|
2244
3134
|
kind: options.kind,
|
|
3135
|
+
idempotencyKey: `cli-library:${randomUUID()}`,
|
|
2245
3136
|
});
|
|
2246
3137
|
requireConfirm(options, 'Uploading an asset');
|
|
2247
3138
|
const signed = await apiFetch(config, options, 'POST', '/api/v1/assets/sign-upload', signBody);
|
|
2248
|
-
|
|
3139
|
+
const uploadUrl = signed?.uploadUrl || signed?.url;
|
|
3140
|
+
if (!uploadUrl || !signed?.intentId || !signed?.assetId) {
|
|
2249
3141
|
throw Object.assign(new Error('Asset sign-upload returned an unexpected response.'), { exitCode: EXIT.SERVER, data: signed });
|
|
2250
3142
|
}
|
|
2251
|
-
await putSignedUpload(
|
|
2252
|
-
const objectPath = objectPathFromKey(signed.key);
|
|
3143
|
+
await putSignedUpload(uploadUrl, resolved, contentType, stat.size, options);
|
|
2253
3144
|
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'),
|
|
3145
|
+
uploadIntentId: signed.intentId,
|
|
2257
3146
|
}), options);
|
|
2258
3147
|
}
|
|
2259
3148
|
|
|
2260
3149
|
async function assets(config, options, action, args) {
|
|
2261
3150
|
if (action === 'list') {
|
|
2262
|
-
|
|
3151
|
+
const response = await apiFetch(config, options, 'GET', `/api/v1/assets${queryString({
|
|
2263
3152
|
type: options.type,
|
|
2264
3153
|
limit: options.limit,
|
|
2265
3154
|
offset: options.offset,
|
|
2266
|
-
})}
|
|
3155
|
+
})}`, undefined, { includeResponseMetadata: true });
|
|
3156
|
+
const items = Array.isArray(response.data) ? response.data : [];
|
|
3157
|
+
const headerTotal = Number(response.headers['x-total-count']);
|
|
3158
|
+
output({
|
|
3159
|
+
items,
|
|
3160
|
+
total: Number.isFinite(headerTotal) ? headerTotal : items.length,
|
|
3161
|
+
limit: numberOption(options.limit, '--limit') ?? items.length,
|
|
3162
|
+
offset: numberOption(options.offset, '--offset') ?? 0,
|
|
3163
|
+
}, options);
|
|
2267
3164
|
return;
|
|
2268
3165
|
}
|
|
2269
3166
|
if (action === 'upload') {
|
|
@@ -2370,13 +3267,83 @@ function socialPlatformList(value) {
|
|
|
2370
3267
|
return platforms.map((platform) => platform.toLowerCase() === 'x' ? 'twitter' : platform);
|
|
2371
3268
|
}
|
|
2372
3269
|
|
|
2373
|
-
function
|
|
3270
|
+
function socialAccountIdPins(value) {
|
|
3271
|
+
if (!value) return {};
|
|
3272
|
+
const raw = String(value).trim();
|
|
3273
|
+
if (!raw) return {};
|
|
3274
|
+
if (raw.startsWith('{')) {
|
|
3275
|
+
const parsed = JSON.parse(raw);
|
|
3276
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
3277
|
+
throw Object.assign(new Error('--account-ids JSON must be an object of platform to account id.'), { exitCode: EXIT.USAGE });
|
|
3278
|
+
}
|
|
3279
|
+
return Object.fromEntries(Object.entries(parsed).map(([platform, accountId]) => [
|
|
3280
|
+
platform.toLowerCase() === 'x' ? 'twitter' : platform.toLowerCase(),
|
|
3281
|
+
requiredString(accountId, `account id for ${platform}`),
|
|
3282
|
+
]));
|
|
3283
|
+
}
|
|
3284
|
+
return Object.fromEntries(raw.split(',').filter(Boolean).map((entry) => {
|
|
3285
|
+
const separator = entry.indexOf('=');
|
|
3286
|
+
if (separator <= 0 || separator === entry.length - 1) {
|
|
3287
|
+
throw Object.assign(new Error('--account-ids must use platform=accountId pairs.'), { exitCode: EXIT.USAGE });
|
|
3288
|
+
}
|
|
3289
|
+
const platform = entry.slice(0, separator).trim().toLowerCase();
|
|
3290
|
+
const accountId = entry.slice(separator + 1).trim();
|
|
3291
|
+
return [platform === 'x' ? 'twitter' : platform, accountId];
|
|
3292
|
+
}));
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
async function socialPostBody(config, options, scheduled) {
|
|
3296
|
+
const clipId = requiredString(options['clip-id'], '--clip-id');
|
|
3297
|
+
const platforms = socialPlatformList(options.platforms);
|
|
3298
|
+
const caption = requiredString(options.caption, '--caption');
|
|
3299
|
+
const requestedExportId = options['export-id'];
|
|
3300
|
+
const deliveryState = await fetchCanonicalClipDeliveryState(
|
|
3301
|
+
config,
|
|
3302
|
+
options,
|
|
3303
|
+
clipId,
|
|
3304
|
+
requestedExportId,
|
|
3305
|
+
);
|
|
3306
|
+
const editorState = requireVerifiedEditorState(deliveryState, clipId);
|
|
3307
|
+
const selectedExport = requireExactCurrentExport(deliveryState, editorState, {
|
|
3308
|
+
requestedExportId,
|
|
3309
|
+
requireReadyToPublish: true,
|
|
3310
|
+
});
|
|
3311
|
+
const accountsResponse = await apiFetch(config, options, 'GET', '/api/v1/social/accounts');
|
|
3312
|
+
const connectedAccounts = Array.isArray(accountsResponse?.accounts)
|
|
3313
|
+
? accountsResponse.accounts.filter((account) => account?.connected && typeof account?.accountId === 'string')
|
|
3314
|
+
: [];
|
|
3315
|
+
const requestedAccountIds = socialAccountIdPins(options['account-ids']);
|
|
3316
|
+
const expectedAccountIds = Object.fromEntries(platforms.map((platform) => {
|
|
3317
|
+
const choices = Array.from(new Set(connectedAccounts
|
|
3318
|
+
.filter((account) => account.platform === platform)
|
|
3319
|
+
.map((account) => account.accountId)));
|
|
3320
|
+
const requestedAccountId = requestedAccountIds[platform];
|
|
3321
|
+
if (requestedAccountId) {
|
|
3322
|
+
if (!choices.includes(requestedAccountId)) {
|
|
3323
|
+
throw Object.assign(new Error(`The selected ${platform} account id is not connected.`), { exitCode: EXIT.USAGE });
|
|
3324
|
+
}
|
|
3325
|
+
return [platform, requestedAccountId];
|
|
3326
|
+
}
|
|
3327
|
+
if (choices.length !== 1) {
|
|
3328
|
+
const reason = choices.length === 0 ? 'no connected account' : 'multiple connected accounts';
|
|
3329
|
+
throw Object.assign(
|
|
3330
|
+
new Error(`${platform} has ${reason}. Run "clipit social accounts --json" and pass --account-ids ${platform}=<accountId>.`),
|
|
3331
|
+
{ exitCode: EXIT.USAGE },
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
return [platform, choices[0]];
|
|
3335
|
+
}));
|
|
2374
3336
|
const body = compactObject({
|
|
2375
|
-
clipId
|
|
2376
|
-
platforms
|
|
2377
|
-
caption
|
|
3337
|
+
clipId,
|
|
3338
|
+
platforms,
|
|
3339
|
+
caption,
|
|
2378
3340
|
title: options.title,
|
|
2379
3341
|
hashtags: stringList(options.hashtags),
|
|
3342
|
+
exportId: selectedExport.exportId,
|
|
3343
|
+
expectedSnapshotId: editorState.snapshotId,
|
|
3344
|
+
expectedOutputObjectFingerprint: selectedExport.outputObjectFingerprint,
|
|
3345
|
+
expectedAccountIds,
|
|
3346
|
+
publishExactCurrentArtifact: true,
|
|
2380
3347
|
});
|
|
2381
3348
|
if (scheduled) body.scheduledFor = requiredString(options.at, '--at');
|
|
2382
3349
|
return body;
|
|
@@ -2388,14 +3355,14 @@ async function social(config, options, action, args) {
|
|
|
2388
3355
|
return;
|
|
2389
3356
|
}
|
|
2390
3357
|
if (action === 'post') {
|
|
2391
|
-
const body = socialPostBody(options, false);
|
|
3358
|
+
const body = await socialPostBody(config, options, false);
|
|
2392
3359
|
await enforceMaxCredits(config, options, 'social post', { body });
|
|
2393
3360
|
confirmPaid(options, 'Publishing a social post');
|
|
2394
3361
|
output(await apiFetch(config, options, 'POST', '/api/v1/social/post', body), options);
|
|
2395
3362
|
return;
|
|
2396
3363
|
}
|
|
2397
3364
|
if (action === 'schedule') {
|
|
2398
|
-
const body = socialPostBody(options, true);
|
|
3365
|
+
const body = await socialPostBody(config, options, true);
|
|
2399
3366
|
await enforceMaxCredits(config, options, 'social schedule', { body });
|
|
2400
3367
|
confirmPaid(options, 'Scheduling a social post');
|
|
2401
3368
|
output(await apiFetch(config, options, 'POST', '/api/v1/social/schedule', body), options);
|
|
@@ -2437,6 +3404,19 @@ async function jobs(config, options, action, args) {
|
|
|
2437
3404
|
while (true) {
|
|
2438
3405
|
const job = await apiFetch(config, options, 'GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`);
|
|
2439
3406
|
if (action === 'get' || TERMINAL_JOB_STATUSES.has(job.status)) {
|
|
3407
|
+
const videoId = job.videoId || job.result?.videoId;
|
|
3408
|
+
const clipId = job.clipId || job.result?.clipId;
|
|
3409
|
+
if (videoId || clipId) {
|
|
3410
|
+
await persistActiveContext(
|
|
3411
|
+
config,
|
|
3412
|
+
options,
|
|
3413
|
+
compactObject({ videoId, clipId }),
|
|
3414
|
+
[
|
|
3415
|
+
videoId ? { type: 'video', id: videoId } : null,
|
|
3416
|
+
clipId ? { type: 'clip', id: clipId } : null,
|
|
3417
|
+
].filter(Boolean),
|
|
3418
|
+
);
|
|
3419
|
+
}
|
|
2440
3420
|
output(job, options);
|
|
2441
3421
|
return;
|
|
2442
3422
|
}
|
|
@@ -3029,7 +4009,7 @@ async function examples(options) {
|
|
|
3029
4009
|
uploadAsset: 'clipit assets upload ./brand-logo.png --kind image --confirm --json',
|
|
3030
4010
|
thumbnail: 'clipit thumbnails generate --clip-id <clipId> --prompt "Expressive high-contrast thumbnail" --confirm --json',
|
|
3031
4011
|
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',
|
|
4012
|
+
socialPost: 'clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json',
|
|
3033
4013
|
runTool: 'clipit run <functionName> --clip-id <clipId> --params @params.json --json',
|
|
3034
4014
|
reviewLink: 'clipit links clip <clipId> --json',
|
|
3035
4015
|
};
|
|
@@ -3062,6 +4042,7 @@ Rules:
|
|
|
3062
4042
|
- 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
4043
|
- Use \`clipit open clip <id>\` when the user should review work in ClipIt.
|
|
3064
4044
|
- Use \`clipit context use --video-id <id>\` or \`clipit context use --clip-id <id>\` to persist the current target for later commands.
|
|
4045
|
+
- 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
4046
|
- Do not write API keys into this skill file or any project files.
|
|
3066
4047
|
|
|
3067
4048
|
Useful commands:
|
|
@@ -3095,7 +4076,7 @@ clipit billing receipt <attemptId> --json
|
|
|
3095
4076
|
clipit analytics overview --days 30 --json
|
|
3096
4077
|
clipit exports start --clip-id <clipId> --confirm --json
|
|
3097
4078
|
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
|
|
4079
|
+
clipit social post --clip-id <clipId> --platforms x,tiktok --account-ids x=<xAccountId>,tiktok=<tiktokAccountId> --caption "New clip" --confirm --json
|
|
3099
4080
|
clipit run renderClipWithRemotion --clip-id <clipId> --params @params.json --confirm --json
|
|
3100
4081
|
\`\`\`
|
|
3101
4082
|
|
|
@@ -3377,6 +4358,7 @@ async function main() {
|
|
|
3377
4358
|
if (command === 'auth' && subcommand === 'set-key') return setKey(config, options);
|
|
3378
4359
|
if (command === 'auth' && subcommand === 'open-settings') return openCommand(config, options, 'settings');
|
|
3379
4360
|
if (command === 'auth' && subcommand === 'profiles') return listProfiles(config, options);
|
|
4361
|
+
if (command === 'auth' && subcommand === 'use') return useProfile(config, options, rest[0]);
|
|
3380
4362
|
if (command === 'context') return contextCommand(config, options, subcommand);
|
|
3381
4363
|
if (command === 'skills' && subcommand === 'list') return listSkills(config, options);
|
|
3382
4364
|
if (command === 'tools' && subcommand === 'list') return listTools(config, options);
|
|
@@ -3416,6 +4398,9 @@ function handleMainError(error) {
|
|
|
3416
4398
|
}), null, 2));
|
|
3417
4399
|
} else {
|
|
3418
4400
|
console.error(message);
|
|
4401
|
+
const details = redactDeep(error.data);
|
|
4402
|
+
if (details?.resumeCommand) console.error(`Resume with: ${details.resumeCommand}`);
|
|
4403
|
+
if (details?.abortCommand) console.error(`Abort the prior upload with: ${details.abortCommand}`);
|
|
3419
4404
|
}
|
|
3420
4405
|
process.exit(exitCode);
|
|
3421
4406
|
}
|
|
@@ -3438,4 +4423,18 @@ if (await isDirectRun()) {
|
|
|
3438
4423
|
main().catch(handleMainError);
|
|
3439
4424
|
}
|
|
3440
4425
|
|
|
3441
|
-
export {
|
|
4426
|
+
export {
|
|
4427
|
+
buildCanonicalExportStartBody,
|
|
4428
|
+
main,
|
|
4429
|
+
redact,
|
|
4430
|
+
clipCostLabel,
|
|
4431
|
+
requireExactCurrentExport,
|
|
4432
|
+
requireVerifiedEditorState,
|
|
4433
|
+
handleMcpRequest,
|
|
4434
|
+
readPositiveIntegerEnv,
|
|
4435
|
+
readSecretLine,
|
|
4436
|
+
readVideoUploadResumes,
|
|
4437
|
+
removeVideoUploadResume,
|
|
4438
|
+
saveVideoUploadResume,
|
|
4439
|
+
shellQuote,
|
|
4440
|
+
};
|