@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/video.mjs ADDED
@@ -0,0 +1,349 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import {
4
+ CliError,
5
+ need,
6
+ id,
7
+ digest,
8
+ readJson,
9
+ writeJson,
10
+ exists,
11
+ subject,
12
+ sameSubject,
13
+ duration,
14
+ pause,
15
+ withWriter,
16
+ errorData,
17
+ } from './core.mjs';
18
+
19
+ export const terminal = (status) =>
20
+ ['succeeded', 'failed', 'cancelled'].includes(status);
21
+ export const resultSummary = (row) => ({
22
+ job_id: row.job_id,
23
+ result_id: row.id,
24
+ shot_id: row.shot_id,
25
+ status: row.status,
26
+ model: row.model,
27
+ provider_phase: row.provider_phase,
28
+ failure_class: row.failure_class,
29
+ error: row.error,
30
+ has_output: Boolean(row.output_url),
31
+ source: 'creative.shots.results',
32
+ });
33
+ export const profileSummary = (profile) => ({
34
+ pricing_key: profile.pricing_key,
35
+ model: profile.model,
36
+ stage: profile.stage,
37
+ display_label: profile.display_label,
38
+ availability: profile.availability,
39
+ allowed_resolutions: profile.allowed_resolutions,
40
+ allowed_durations: profile.allowed_durations,
41
+ // DEV-2175:可調步數的模型列出範圍與預設;沒宣告就是固定步數,請求不能帶 params.steps。
42
+ ...(profile.capability.steps ? { steps: profile.capability.steps } : {}),
43
+ });
44
+ export async function models(action, options, { client }) {
45
+ const data = await client.get('/generation/options');
46
+ const profiles = data.profiles.filter(
47
+ (profile) =>
48
+ !options.stage ||
49
+ profile.stage === (options.stage === 'video' ? 'i2v' : options.stage),
50
+ );
51
+ if (action === 'list')
52
+ return {
53
+ profiles: options.full ? profiles : profiles.map(profileSummary),
54
+ };
55
+ const profile = profiles.find(
56
+ (item) =>
57
+ item.pricing_key === need(options.key, '--key (exact pricing key)'),
58
+ );
59
+ if (!profile)
60
+ throw new CliError(
61
+ 2,
62
+ 'PROFILE_NOT_FOUND',
63
+ 'This exact pricing key is not in the current options.',
64
+ );
65
+ return profile;
66
+ }
67
+ export async function observeVideo(client, receipt) {
68
+ const creative = await client.get(
69
+ `/storyboards/${id(receipt.creative_id, 'creative_id')}`,
70
+ );
71
+ const shot = creative.shots.find(
72
+ (item) => item.id === receipt.request.shot_id,
73
+ );
74
+ const row = shot?.results.find((item) =>
75
+ receipt.accepted
76
+ ? item.job_id === receipt.accepted.job_id &&
77
+ item.id === receipt.accepted.result_id
78
+ : false,
79
+ );
80
+ if (!receipt.accepted) return null;
81
+ if (!row)
82
+ throw new CliError(
83
+ 4,
84
+ 'JOB_NOT_VISIBLE',
85
+ 'The bound Job/Take is not visible in its Creative/Shot. No replacement was submitted.',
86
+ );
87
+ return resultSummary(row);
88
+ }
89
+ // A plain video submission posts a CreateJob video body to /generation/jobs. Submitting a
90
+ // Shot version posts the same body shape to that version; its stage follows the version.
91
+ export function validateIntent(request, key, endpoint = '/generation/jobs') {
92
+ const stages = endpoint === '/generation/jobs' ? ['i2v'] : ['t2i', 'i2v'];
93
+ if (
94
+ !stages.includes(request.stage) ||
95
+ !Number.isInteger(request.shot_id) ||
96
+ request.shot_id < 1
97
+ )
98
+ throw new CliError(
99
+ 2,
100
+ 'INVALID_SHOT',
101
+ endpoint === '/generation/jobs'
102
+ ? 'The request must be a CreateJob video body: stage "i2v" and the server numeric shot_id.'
103
+ : 'The request must carry the version stage (t2i or i2v) and the server numeric shot_id.',
104
+ );
105
+ if (
106
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
107
+ key,
108
+ )
109
+ )
110
+ throw new CliError(
111
+ 2,
112
+ 'INVALID_IDEMPOTENCY_KEY',
113
+ 'Use a UUID idempotency key.',
114
+ );
115
+ }
116
+ export async function acceptVideo(client, receipt, file) {
117
+ if (digest(receipt.request) !== receipt.request_sha256)
118
+ throw new CliError(
119
+ 2,
120
+ 'INTENT_CHANGED',
121
+ 'Stored request hash does not match its body.',
122
+ );
123
+ if (receipt.accepted) return receipt;
124
+ // This file already contains the exact body/key/subject before the network call.
125
+ receipt.state = 'submitting';
126
+ await writeJson(file, receipt);
127
+ try {
128
+ const reply = await client.post(
129
+ receipt.endpoint ?? '/generation/jobs',
130
+ receipt.request,
131
+ { headers: { 'X-Idempotency-Key': receipt.idempotency_key } },
132
+ );
133
+ // Shot video acceptance returns its result rows. Only the explicit replay
134
+ // envelope carries order/results when this same key is replayed.
135
+ const replayed = reply?.replayed === true;
136
+ const row = replayed ? reply.results?.[0] : reply?.[0];
137
+ if (
138
+ !row?.job_id ||
139
+ !row?.id ||
140
+ row.shot_id !== receipt.request.shot_id ||
141
+ (replayed && reply.order?.id !== row.job_id)
142
+ ) {
143
+ throw new CliError(
144
+ 5,
145
+ 'RESPONSE_UNKNOWN',
146
+ 'The acceptance response did not identify the expected Job/Take. Reconcile this exact key.',
147
+ );
148
+ }
149
+ receipt.accepted = {
150
+ job_id: row.job_id,
151
+ result_id: row.id,
152
+ shot_id: row.shot_id,
153
+ replayed,
154
+ };
155
+ receipt.state = 'accepted';
156
+ receipt.observation = resultSummary(row);
157
+ delete receipt.error;
158
+ await writeJson(file, receipt);
159
+ return receipt;
160
+ } catch (error) {
161
+ if (!(error instanceof CliError))
162
+ error = new CliError(
163
+ 5,
164
+ 'RECEIPT_UNKNOWN',
165
+ 'Could not finish recording acceptance. Reconcile the saved body and key.',
166
+ { receipt: file },
167
+ );
168
+ receipt.state =
169
+ error.exit === 5
170
+ ? 'unknown'
171
+ : error.exit === 3
172
+ ? 'auth_required'
173
+ : error.http_status === 429 ||
174
+ error.code === 'CONCURRENCY_LIMIT_REACHED'
175
+ ? 'waiting'
176
+ : 'rejected';
177
+ receipt.error = errorData(error);
178
+ await writeJson(file, receipt);
179
+ error.receipt = file;
180
+ throw error;
181
+ }
182
+ }
183
+ export async function videoSubmit(options, context) {
184
+ if (options['retry-rejected'] && !options.resume)
185
+ throw new CliError(
186
+ 2,
187
+ 'RESUME_REQUIRED',
188
+ '--retry-rejected requires --resume and the original receipt.',
189
+ );
190
+ const file = path.resolve(need(options.receipt, '--receipt'));
191
+ return withWriter(`${file}.lock`, async () => {
192
+ const owner = await subject(context.client);
193
+ let receipt;
194
+ if (await exists(file)) {
195
+ receipt = await readJson(file);
196
+ if (receipt.schema_version !== 1 || receipt.kind !== 'video')
197
+ throw new CliError(
198
+ 2,
199
+ 'WRONG_RECEIPT',
200
+ 'Expected a version 1 video receipt.',
201
+ );
202
+ validateIntent(receipt.request, receipt.idempotency_key);
203
+ if (digest(receipt.request) !== receipt.request_sha256)
204
+ throw new CliError(
205
+ 2,
206
+ 'INTENT_CHANGED',
207
+ 'The saved video request changed.',
208
+ );
209
+ sameSubject(receipt, owner);
210
+ if (!options.resume)
211
+ throw new CliError(
212
+ 2,
213
+ 'RECEIPT_EXISTS',
214
+ 'Use --resume to reconcile this fixed intent; a new generation needs a new receipt.',
215
+ );
216
+ if (receipt.accepted) {
217
+ receipt.observation = await observeVideo(context.client, receipt);
218
+ await writeJson(file, receipt);
219
+ return {
220
+ receipt: file,
221
+ ...receipt.accepted,
222
+ observation: receipt.observation,
223
+ };
224
+ }
225
+ if (receipt.state === 'rejected') {
226
+ if (!options['retry-rejected'])
227
+ throw new CliError(
228
+ 4,
229
+ 'INTENT_REJECTED',
230
+ 'Review the rejection before explicitly retrying this unchanged intent with --resume --retry-rejected.',
231
+ { receipt: file },
232
+ );
233
+ receipt.rejected_attempts = [
234
+ ...(receipt.rejected_attempts || []),
235
+ { observed_at: new Date().toISOString(), error: receipt.error },
236
+ ];
237
+ }
238
+ await observeVideo(context.client, receipt);
239
+ } else {
240
+ if (options.resume)
241
+ throw new CliError(
242
+ 2,
243
+ 'RECEIPT_NOT_FOUND',
244
+ 'Resume requires the existing receipt file.',
245
+ );
246
+ const request = await readJson(
247
+ need(options['request-file'], '--request-file'),
248
+ );
249
+ const key = options['idempotency-key'] || randomUUID();
250
+ validateIntent(request, key);
251
+ receipt = {
252
+ schema_version: 1,
253
+ kind: 'video',
254
+ ...owner,
255
+ creative_id: need(options.creative, '--creative'),
256
+ idempotency_key: key,
257
+ request,
258
+ request_sha256: digest(request),
259
+ state: 'prepared',
260
+ created_at: new Date().toISOString(),
261
+ };
262
+ const creative = await context.client.get(
263
+ `/storyboards/${id(receipt.creative_id)}`,
264
+ );
265
+ if (!creative.shots.some((shot) => shot.id === request.shot_id))
266
+ throw new CliError(
267
+ 2,
268
+ 'TARGET_MISMATCH',
269
+ 'The Shot does not belong to this Creative.',
270
+ );
271
+ await writeJson(file, receipt, true);
272
+ }
273
+ await acceptVideo(context.client, receipt, file);
274
+ return {
275
+ receipt: file,
276
+ ...receipt.accepted,
277
+ observation: receipt.observation,
278
+ };
279
+ });
280
+ }
281
+ // 還在 FUSE 排隊的鏡頭影片,或已派到自家機器的開源 take,都由同一個端點取消(DEV-2189);
282
+ // 伺服器判斷可不可以、退不退點,CLI 不另外推斷。
283
+ export const cancelJob = (client, jobId) =>
284
+ client.post(`/generation/jobs/${id(jobId, 'job')}/cancel`);
285
+ export async function cancel(options, context) {
286
+ if (!options.receipt)
287
+ return cancelJob(context.client, need(options.job, '--receipt or --job'));
288
+ const receipt = await readJson(options.receipt);
289
+ sameSubject(receipt, await subject(context.client));
290
+ if (!receipt.accepted)
291
+ throw new CliError(
292
+ 5,
293
+ 'ACCEPTANCE_UNKNOWN',
294
+ 'No canonical Job is recorded. Use video submit --resume with this receipt.',
295
+ { receipt: options.receipt },
296
+ );
297
+ return {
298
+ ...(await cancelJob(context.client, receipt.accepted.job_id)),
299
+ receipt: options.receipt,
300
+ };
301
+ }
302
+ export async function job(action, options, context) {
303
+ let receipt;
304
+ if (options.receipt) {
305
+ receipt = await readJson(options.receipt);
306
+ sameSubject(receipt, await subject(context.client));
307
+ } else
308
+ receipt = {
309
+ creative_id: need(options.creative, '--creative'),
310
+ request: { shot_id: Number(need(options.shot, '--shot')) },
311
+ accepted: {
312
+ job_id: need(options.job, '--job'),
313
+ result_id: need(options.result, '--result'),
314
+ },
315
+ };
316
+ if (!receipt.accepted)
317
+ throw new CliError(
318
+ 5,
319
+ 'ACCEPTANCE_UNKNOWN',
320
+ 'No canonical Job is recorded. Use video submit --resume with this receipt.',
321
+ { receipt: options.receipt },
322
+ );
323
+ const deadline = Date.now() + duration(options.timeout);
324
+ let previous;
325
+ do {
326
+ const row = await observeVideo(context.client, receipt);
327
+ if (action === 'show') return row;
328
+ const key = JSON.stringify(row);
329
+ if (key !== previous) {
330
+ context.event('job_status', row);
331
+ previous = key;
332
+ }
333
+ if (terminal(row.status)) {
334
+ if (row.status !== 'succeeded')
335
+ throw new CliError(
336
+ 7,
337
+ 'JOB_FAILED',
338
+ 'The server reports a failed or cancelled Job.',
339
+ { data: row },
340
+ );
341
+ return row;
342
+ }
343
+ if (Date.now() >= deadline)
344
+ throw new CliError(6, 'WATCH_TIMEOUT', 'The Job is still nonterminal.', {
345
+ data: row,
346
+ });
347
+ await pause(Math.min(5000, deadline - Date.now()), context.signal);
348
+ } while (true);
349
+ }