@alchemint/fuse-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/batch.mjs ADDED
@@ -0,0 +1,523 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import {
4
+ CliError,
5
+ need,
6
+ id,
7
+ readJson,
8
+ writeJson,
9
+ exists,
10
+ digest,
11
+ sameSubject,
12
+ subject,
13
+ duration,
14
+ pause,
15
+ withWriter,
16
+ errorData,
17
+ } from './core.mjs';
18
+ import {
19
+ acceptVideo,
20
+ observeVideo,
21
+ validateIntent,
22
+ terminal,
23
+ cancelJob,
24
+ } from './video.mjs';
25
+ import { download, fileDigest } from './download.mjs';
26
+
27
+ const receiptPath = (directory, index) =>
28
+ path.join(directory, `item-${String(index + 1).padStart(4, '0')}.json`);
29
+ const planHash = ({ plan_sha256, ...plan }) => digest(plan);
30
+ const validPlan = (plan) => {
31
+ if (plan.schema_version !== 1 || plan.plan_sha256 !== planHash(plan))
32
+ throw new CliError(
33
+ 2,
34
+ 'PLAN_CHANGED',
35
+ 'The frozen plan hash/version does not match. Do not edit a started plan.',
36
+ );
37
+ };
38
+ async function prepare(inputFile, out, context) {
39
+ const manifest = await readJson(inputFile);
40
+ if (
41
+ manifest.schema_version !== 1 ||
42
+ !Array.isArray(manifest.items) ||
43
+ !manifest.items.length
44
+ )
45
+ throw new CliError(
46
+ 2,
47
+ 'INVALID_MANIFEST',
48
+ 'Expected schema_version:1 and a nonempty items array.',
49
+ );
50
+ const owner = await subject(context.client);
51
+ const options = await context.client.get('/generation/options');
52
+ const targets = new Map();
53
+ const keys = new Set();
54
+ const idem = new Set();
55
+ const items = [];
56
+ for (const input of manifest.items) {
57
+ if (!input.key || keys.has(input.key))
58
+ throw new CliError(
59
+ 2,
60
+ 'DUPLICATE_ITEM',
61
+ 'Each manifest item needs a unique key.',
62
+ );
63
+ keys.add(input.key);
64
+ const request = await readJson(
65
+ path.resolve(
66
+ path.dirname(inputFile),
67
+ need(input.request_file, 'item.request_file'),
68
+ ),
69
+ );
70
+ const key = input.idempotency_key || randomUUID();
71
+ validateIntent(request, key);
72
+ if (idem.has(key.toLowerCase()))
73
+ throw new CliError(
74
+ 2,
75
+ 'DUPLICATE_IDEMPOTENCY_KEY',
76
+ 'Each generation intent needs its own fixed key.',
77
+ );
78
+ idem.add(key.toLowerCase());
79
+ const profile = options.profiles.find(
80
+ (item) =>
81
+ item.pricing_key === request.generation_pricing_key &&
82
+ item.stage === 'i2v',
83
+ );
84
+ if (!profile)
85
+ throw new CliError(
86
+ 2,
87
+ 'PROFILE_NOT_FOUND',
88
+ `No video pricing profile for ${input.key}.`,
89
+ );
90
+ if (!targets.has(input.creative_id))
91
+ targets.set(
92
+ input.creative_id,
93
+ await context.client.get(
94
+ `/storyboards/${id(input.creative_id, 'item.creative_id')}`,
95
+ ),
96
+ );
97
+ const creative = targets.get(input.creative_id);
98
+ if (!creative.shots.some((shot) => shot.id === request.shot_id))
99
+ throw new CliError(
100
+ 2,
101
+ 'TARGET_MISMATCH',
102
+ `Shot does not belong to the specified Creative: ${input.key}`,
103
+ );
104
+ const item = {
105
+ key: input.key,
106
+ creative_id: input.creative_id,
107
+ idempotency_key: key,
108
+ request,
109
+ request_sha256: digest(request),
110
+ model: profile.model,
111
+ ...(input.source ? { source: input.source } : {}),
112
+ };
113
+ // Import an existing exact intent; never create a second canary key.
114
+ if (input.receipt_file) {
115
+ const receipt = await readJson(
116
+ path.resolve(path.dirname(inputFile), input.receipt_file),
117
+ );
118
+ sameSubject(receipt, owner);
119
+ if (
120
+ receipt.idempotency_key !== key ||
121
+ receipt.creative_id !== item.creative_id ||
122
+ digest(receipt.request) !== item.request_sha256 ||
123
+ !receipt.accepted
124
+ )
125
+ throw new CliError(
126
+ 2,
127
+ 'IMPORT_MISMATCH',
128
+ `Existing receipt does not match this exact intent: ${input.key}`,
129
+ );
130
+ await observeVideo(context.client, receipt);
131
+ item.accepted = receipt.accepted;
132
+ } else if (input.accepted) {
133
+ item.accepted = {
134
+ job_id: need(input.accepted.job_id, 'accepted.job_id'),
135
+ result_id: need(input.accepted.result_id, 'accepted.result_id'),
136
+ shot_id: request.shot_id,
137
+ replayed: false,
138
+ };
139
+ await observeVideo(context.client, item);
140
+ }
141
+ items.push(item);
142
+ }
143
+ const plan = {
144
+ schema_version: 1,
145
+ kind: 'video_batch',
146
+ name: manifest.name,
147
+ ...owner,
148
+ created_at: new Date().toISOString(),
149
+ items,
150
+ };
151
+ plan.plan_sha256 = planHash(plan);
152
+ await writeJson(out, plan, true);
153
+ return {
154
+ plan: path.resolve(out),
155
+ plan_sha256: plan.plan_sha256,
156
+ count: items.length,
157
+ imported: items.filter((item) => item.accepted).length,
158
+ models: [...new Set(items.map((item) => item.model))],
159
+ items: items.map((item) => ({
160
+ key: item.key,
161
+ creative_id: item.creative_id,
162
+ shot_id: item.request.shot_id,
163
+ model: item.model,
164
+ pricing_key: item.request.generation_pricing_key,
165
+ requested_duration: item.request.params?.duration,
166
+ request_sha256: item.request_sha256,
167
+ references: {
168
+ images: item.request.reference_images?.length || 0,
169
+ videos: item.request.reference_videos?.length || 0,
170
+ audios: item.request.reference_audios?.length || 0,
171
+ },
172
+ })),
173
+ };
174
+ }
175
+ async function loadState(directory, context) {
176
+ const plan = await readJson(path.join(directory, 'plan.json'));
177
+ validPlan(plan);
178
+ sameSubject(plan, await subject(context.client));
179
+ return plan;
180
+ }
181
+ function newReceipt(plan, item) {
182
+ return {
183
+ schema_version: 1,
184
+ kind: 'video',
185
+ api_url: plan.api_url,
186
+ user_id: plan.user_id,
187
+ workspace_id: plan.workspace_id,
188
+ ...item,
189
+ state: item.accepted ? 'accepted' : 'prepared',
190
+ created_at: plan.created_at,
191
+ };
192
+ }
193
+ function matchReceipt(plan, item, receipt) {
194
+ sameSubject(plan, receipt);
195
+ if (
196
+ item.idempotency_key !== receipt.idempotency_key ||
197
+ item.creative_id !== receipt.creative_id ||
198
+ item.request_sha256 !== digest(receipt.request)
199
+ )
200
+ throw new CliError(
201
+ 2,
202
+ 'INTENT_CHANGED',
203
+ `State differs from its frozen plan: ${item.key}`,
204
+ );
205
+ }
206
+ async function status(directory, plan, context) {
207
+ const rows = [];
208
+ for (let index = 0; index < plan.items.length; index++) {
209
+ const item = plan.items[index];
210
+ const file = receiptPath(directory, index);
211
+ const receipt = (await exists(file))
212
+ ? await readJson(file)
213
+ : newReceipt(plan, item);
214
+ matchReceipt(plan, item, receipt);
215
+ const observation = receipt.accepted
216
+ ? await observeVideo(context.client, receipt)
217
+ : null;
218
+ rows.push({
219
+ key: item.key,
220
+ state: receipt.state,
221
+ ...(receipt.accepted || {}),
222
+ observation,
223
+ receipt: file,
224
+ });
225
+ }
226
+ return {
227
+ state: path.resolve(directory),
228
+ count: rows.length,
229
+ accepted: rows.filter((row) => row.job_id).length,
230
+ completed: rows.filter((row) => row.observation?.status === 'succeeded')
231
+ .length,
232
+ failed: rows.filter(
233
+ (row) => row.observation?.status === 'failed' || row.state === 'rejected',
234
+ ).length,
235
+ cancelled: rows.filter(
236
+ (row) =>
237
+ row.state === 'cancelled' || row.observation?.status === 'cancelled',
238
+ ).length,
239
+ unknown: rows.filter((row) => ['unknown', 'submitting'].includes(row.state))
240
+ .length,
241
+ items: rows,
242
+ };
243
+ }
244
+ async function run(directory, plan, options, context) {
245
+ const deadline = Date.now() + duration(options.timeout || '2h');
246
+ const limit = options.limit ? Number(options.limit) : plan.items.length;
247
+ if (!Number.isInteger(limit) || limit < 1)
248
+ throw new CliError(
249
+ 2,
250
+ 'INVALID_LIMIT',
251
+ '--limit must be a positive number of new submissions.',
252
+ );
253
+ let submitted = 0;
254
+ for (let index = 0; index < plan.items.length; index++) {
255
+ if (context.signal.aborted)
256
+ throw new CliError(
257
+ 6,
258
+ 'INTERRUPTED',
259
+ 'Stopped local submissions; accepted jobs continue.',
260
+ );
261
+ const item = plan.items[index];
262
+ const file = receiptPath(directory, index);
263
+ const receipt = (await exists(file))
264
+ ? await readJson(file)
265
+ : newReceipt(plan, item);
266
+ matchReceipt(plan, item, receipt);
267
+ if (!(await exists(file))) await writeJson(file, receipt, true);
268
+ if (receipt.accepted) {
269
+ receipt.observation = await observeVideo(context.client, receipt);
270
+ await writeJson(file, receipt);
271
+ continue;
272
+ }
273
+ if (receipt.state === 'rejected' || receipt.state === 'cancelled') continue;
274
+ if (submitted >= limit) break;
275
+ if (receipt.state === 'unknown' || receipt.state === 'submitting')
276
+ await observeVideo(context.client, receipt);
277
+ for (;;) {
278
+ if (Date.now() >= deadline)
279
+ throw new CliError(
280
+ 6,
281
+ 'BATCH_TIMEOUT',
282
+ 'Batch submission deadline reached; resume the same state.',
283
+ { receipt: file },
284
+ );
285
+ const current = await context.client.get('/generation/options');
286
+ const profile = current.profiles.find(
287
+ (entry) => entry.pricing_key === receipt.request.generation_pricing_key,
288
+ );
289
+ if (!profile || profile.model !== item.model)
290
+ throw new CliError(
291
+ 4,
292
+ 'PROFILE_CHANGED',
293
+ `The pricing profile identity changed for ${item.key}; keep this plan unchanged.`,
294
+ );
295
+ try {
296
+ await acceptVideo(context.client, receipt, file);
297
+ submitted++;
298
+ context.progress(`${item.key}: accepted ${receipt.accepted.job_id}`);
299
+ break;
300
+ } catch (error) {
301
+ if (receipt.state !== 'waiting') throw error;
302
+ context.progress(`${item.key}: waiting for server admission`);
303
+ await pause(
304
+ Math.min(
305
+ (error.retry_after || 5) * 1000,
306
+ Math.max(0, deadline - Date.now()),
307
+ ),
308
+ context.signal,
309
+ );
310
+ }
311
+ }
312
+ }
313
+ const result = await status(directory, plan, context);
314
+ if (result.items.some((item) => item.state === 'rejected'))
315
+ throw new CliError(
316
+ 4,
317
+ 'BATCH_REJECTED',
318
+ 'Some fixed intents were rejected; they were not replaced.',
319
+ { data: result },
320
+ );
321
+ if (result.items.some((item) => !item.job_id && item.state !== 'cancelled'))
322
+ throw new CliError(
323
+ 6,
324
+ 'BATCH_PARTIAL',
325
+ 'Submission limit reached; resume this state to continue.',
326
+ { data: result },
327
+ );
328
+ return result;
329
+ }
330
+ async function collect(directory, plan, options, context) {
331
+ const rows = [];
332
+ let pending = 0,
333
+ failed = 0;
334
+ for (let index = 0; index < plan.items.length; index++) {
335
+ const file = receiptPath(directory, index),
336
+ item = plan.items[index];
337
+ const receipt = (await exists(file))
338
+ ? await readJson(file)
339
+ : newReceipt(plan, item);
340
+ matchReceipt(plan, item, receipt);
341
+ if (!receipt.accepted) {
342
+ if (receipt.state === 'rejected') failed++;
343
+ else if (receipt.state !== 'cancelled') pending++;
344
+ rows.push({ key: item.key, state: receipt.state });
345
+ continue;
346
+ }
347
+ const observation = await observeVideo(context.client, receipt);
348
+ if (!terminal(observation.status)) {
349
+ pending++;
350
+ rows.push({ key: item.key, status: observation.status });
351
+ continue;
352
+ }
353
+ if (observation.status !== 'succeeded') {
354
+ if (observation.status === 'failed') failed++;
355
+ rows.push({ key: item.key, status: observation.status });
356
+ continue;
357
+ }
358
+ const output = path.resolve(
359
+ need(options.out, '--out'),
360
+ `${String(index + 1).padStart(4, '0')}-${item.key.replace(/[^\p{L}\p{N}._-]/gu, '_')}.mp4`,
361
+ );
362
+ let media;
363
+ if (receipt.download?.file === output && (await exists(output))) {
364
+ const current = await fileDigest(output);
365
+ if (
366
+ current.bytes !== receipt.download.bytes ||
367
+ current.sha256 !== receipt.download.sha256
368
+ )
369
+ throw new CliError(
370
+ 2,
371
+ 'OUTPUT_CHANGED',
372
+ `Existing downloaded file changed: ${output}`,
373
+ );
374
+ media = receipt.download;
375
+ } else {
376
+ media = await download(
377
+ 'take',
378
+ {
379
+ creative: item.creative_id,
380
+ shot: item.request.shot_id,
381
+ result: receipt.accepted.result_id,
382
+ out: output,
383
+ },
384
+ context,
385
+ );
386
+ receipt.download = media;
387
+ await writeJson(file, receipt);
388
+ }
389
+ rows.push({ key: item.key, ...media });
390
+ }
391
+ const result = {
392
+ downloaded: rows.filter((row) => row.downloaded).length,
393
+ pending,
394
+ failed,
395
+ items: rows,
396
+ };
397
+ if (pending || failed)
398
+ throw new CliError(
399
+ pending ? 6 : 7,
400
+ pending ? 'BATCH_PENDING' : 'BATCH_FAILED',
401
+ 'Available completed Takes were downloaded; remaining rows retain their actual state.',
402
+ { data: result },
403
+ );
404
+ return result;
405
+ }
406
+ // 批次取消:已受理、未終局的 Job 交給伺服器取消(FUSE 排隊中的,以及已在機器上的開源 take);
407
+ // 尚未送出的項目在本地撤回,resume 不再送出。閉源已送到 provider(伺服器拒絕)、已結束或
408
+ // 受理結果不明的照實列出,不重送、不猜測。
409
+ async function cancelBatch(directory, plan, context) {
410
+ const rows = [];
411
+ for (let index = 0; index < plan.items.length; index++) {
412
+ const item = plan.items[index];
413
+ const file = receiptPath(directory, index);
414
+ const saved = await exists(file);
415
+ const receipt = saved ? await readJson(file) : newReceipt(plan, item);
416
+ matchReceipt(plan, item, receipt);
417
+ if (!receipt.accepted) {
418
+ if (['prepared', 'waiting', 'auth_required'].includes(receipt.state)) {
419
+ receipt.state = 'cancelled';
420
+ await writeJson(file, receipt, !saved);
421
+ rows.push({ key: item.key, outcome: 'withdrawn', receipt: file });
422
+ } else
423
+ rows.push({
424
+ key: item.key,
425
+ outcome: receipt.state === 'cancelled' ? 'withdrawn' : receipt.state,
426
+ receipt: file,
427
+ });
428
+ continue;
429
+ }
430
+ const observation = await observeVideo(context.client, receipt);
431
+ if (terminal(observation.status)) {
432
+ rows.push({
433
+ key: item.key,
434
+ job_id: receipt.accepted.job_id,
435
+ outcome: observation.status,
436
+ });
437
+ continue;
438
+ }
439
+ try {
440
+ const result = await cancelJob(context.client, receipt.accepted.job_id);
441
+ rows.push({
442
+ key: item.key,
443
+ job_id: receipt.accepted.job_id,
444
+ outcome: result.status,
445
+ });
446
+ } catch (error) {
447
+ if (!(error instanceof CliError)) throw error;
448
+ rows.push({
449
+ key: item.key,
450
+ job_id: receipt.accepted.job_id,
451
+ outcome:
452
+ error.code === 'GENERATION_JOB_CANCEL_FORBIDDEN'
453
+ ? 'already_submitted'
454
+ : 'error',
455
+ status: observation.status,
456
+ ...(error.code === 'GENERATION_JOB_CANCEL_FORBIDDEN'
457
+ ? {}
458
+ : { error: errorData(error) }),
459
+ });
460
+ }
461
+ }
462
+ const count = (outcome) =>
463
+ rows.filter((row) => row.outcome === outcome).length;
464
+ const result = {
465
+ state: path.resolve(directory),
466
+ count: rows.length,
467
+ cancelled: count('cancelled'),
468
+ withdrawn: count('withdrawn'),
469
+ already_submitted: count('already_submitted'),
470
+ unknown: rows.filter((row) =>
471
+ ['unknown', 'submitting', 'error'].includes(row.outcome),
472
+ ).length,
473
+ items: rows,
474
+ };
475
+ if (result.unknown)
476
+ throw new CliError(
477
+ 5,
478
+ 'BATCH_CANCEL_UNKNOWN',
479
+ 'Some items could not be confirmed; resolve them (batch resume for unknown acceptance) and run batch cancel again.',
480
+ { data: result },
481
+ );
482
+ return result;
483
+ }
484
+ export async function batch(action, positional, options, context) {
485
+ if (action === 'plan')
486
+ return prepare(
487
+ path.resolve(need(positional, 'manifest file')),
488
+ need(options.out, '--out'),
489
+ context,
490
+ );
491
+ const directory = path.resolve(
492
+ need(action === 'run' ? options.state : positional, 'state directory'),
493
+ );
494
+ if (action === 'status')
495
+ return status(directory, await loadState(directory, context), context);
496
+ return withWriter(directory, async () => {
497
+ const savedPlan = path.join(directory, 'plan.json');
498
+ if (action === 'run') {
499
+ const input = await readJson(need(positional, 'plan file'));
500
+ validPlan(input);
501
+ sameSubject(input, await subject(context.client));
502
+ if (await exists(savedPlan)) {
503
+ if ((await readJson(savedPlan)).plan_sha256 !== input.plan_sha256)
504
+ throw new CliError(
505
+ 2,
506
+ 'STATE_PLAN_MISMATCH',
507
+ 'This state belongs to a different plan.',
508
+ );
509
+ } else await writeJson(savedPlan, input, true);
510
+ }
511
+ const plan = await loadState(directory, context);
512
+ if (action === 'download')
513
+ return collect(directory, plan, options, context);
514
+ if (action === 'cancel') return cancelBatch(directory, plan, context);
515
+ if (!['run', 'resume'].includes(action))
516
+ throw new CliError(
517
+ 2,
518
+ 'UNKNOWN_COMMAND',
519
+ 'Use batch plan, run, status, resume, download, or cancel.',
520
+ );
521
+ return run(directory, plan, options, context);
522
+ });
523
+ }