@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/README.md +48 -2
- package/package.json +20 -4
- package/src/assets.mjs +386 -0
- package/src/auth.mjs +132 -0
- package/src/batch.mjs +523 -0
- package/src/core.mjs +353 -0
- package/src/creative.mjs +379 -0
- package/src/download.mjs +121 -0
- package/src/element-images.mjs +257 -0
- package/src/main.mjs +276 -0
- package/src/mutations.mjs +125 -0
- package/src/prompt.mjs +121 -0
- package/src/versions.mjs +358 -0
- package/src/video.mjs +349 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { CliError, need, id } from './core.mjs';
|
|
3
|
+
import { uploadWithReceipt } from './assets.mjs';
|
|
4
|
+
|
|
5
|
+
// DEV-2053 / DEV-2058: a character's, scene's or prop's images live on the
|
|
6
|
+
// library Asset it points to — one 角色設定圖/主圖 (versions, one current)
|
|
7
|
+
// plus 其他圖片; every Creative that points at the Asset sees all of them
|
|
8
|
+
// (DEV-2077 retired the per-Creative picks).
|
|
9
|
+
const ROLES = ['setting', 'other'];
|
|
10
|
+
|
|
11
|
+
const NOUNS = {
|
|
12
|
+
character: {
|
|
13
|
+
flag: '--character',
|
|
14
|
+
option: 'character',
|
|
15
|
+
idKey: 'character_id',
|
|
16
|
+
segment: 'characters',
|
|
17
|
+
readList: 'characters',
|
|
18
|
+
setting: '角色設定圖',
|
|
19
|
+
elementsOf: (elements) =>
|
|
20
|
+
elements.characters.map((row) => ({ id: row.id, name: row.name })),
|
|
21
|
+
},
|
|
22
|
+
art: {
|
|
23
|
+
flag: '--element',
|
|
24
|
+
option: 'element',
|
|
25
|
+
idKey: 'element_id',
|
|
26
|
+
segment: 'art-elements',
|
|
27
|
+
readList: 'art_elements',
|
|
28
|
+
setting: '主圖',
|
|
29
|
+
elementsOf: (elements) => [
|
|
30
|
+
...elements.scene_settings.map((row) => ({
|
|
31
|
+
id: row.id,
|
|
32
|
+
name: row.name,
|
|
33
|
+
type: 'scene',
|
|
34
|
+
})),
|
|
35
|
+
...elements.props.map((row) => ({
|
|
36
|
+
id: row.id,
|
|
37
|
+
name: row.name,
|
|
38
|
+
type: 'prop',
|
|
39
|
+
})),
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const elementImages = (client, creativeId) =>
|
|
45
|
+
client.get(`/storyboards/${id(creativeId, '--creative')}/image-slots`);
|
|
46
|
+
const librarySummary = (asset) =>
|
|
47
|
+
asset && {
|
|
48
|
+
asset_id: asset.asset_id,
|
|
49
|
+
name: asset.name,
|
|
50
|
+
usable: asset.usable,
|
|
51
|
+
...(asset.usable
|
|
52
|
+
? { setting_result_id: asset.representation_result_id }
|
|
53
|
+
: { reason: asset.reason }),
|
|
54
|
+
};
|
|
55
|
+
const imagesSummary = (images) => ({
|
|
56
|
+
ready: images.ready,
|
|
57
|
+
library_asset: librarySummary(images.library_asset),
|
|
58
|
+
setting: {
|
|
59
|
+
current_result_id: images.setting.current_version_id,
|
|
60
|
+
versions: images.setting.versions.map((version) => ({
|
|
61
|
+
result_id: version.id,
|
|
62
|
+
source: version.source,
|
|
63
|
+
status: version.status,
|
|
64
|
+
upload_name: version.upload_name,
|
|
65
|
+
})),
|
|
66
|
+
},
|
|
67
|
+
other_images: images.other_images.map((image) => ({
|
|
68
|
+
result_id: image.id,
|
|
69
|
+
name: image.name,
|
|
70
|
+
})),
|
|
71
|
+
});
|
|
72
|
+
// Every image of the element as it reads now: setting versions and other images.
|
|
73
|
+
const imageIdsOf = (images) => [
|
|
74
|
+
...images.setting.versions.map((version) => version.id),
|
|
75
|
+
...images.other_images.map((image) => image.id),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
function command(nounKey) {
|
|
79
|
+
const noun = NOUNS[nounKey];
|
|
80
|
+
const imagesOf = (read, elementId) =>
|
|
81
|
+
read[noun.readList].find((item) => item[noun.idKey] === elementId);
|
|
82
|
+
const endpoint = (creativeId, elementId) =>
|
|
83
|
+
`/storyboards/${id(creativeId, '--creative')}/story-elements/${noun.segment}/${id(elementId, noun.flag)}`;
|
|
84
|
+
|
|
85
|
+
// image-uploads: setting → a candidate 角色設定圖/主圖 version on the
|
|
86
|
+
// element's Asset; other → a 其他圖片 on the Asset.
|
|
87
|
+
// The first upload creates the library Asset. The receipt keeps the images
|
|
88
|
+
// from before the upload, so the new one identifies where it landed.
|
|
89
|
+
const upload = (options, context) => ({
|
|
90
|
+
kind: `${nounKey}_image_upload`,
|
|
91
|
+
command: `${nounKey} upload`,
|
|
92
|
+
async metadata(media) {
|
|
93
|
+
const role = need(options.role, '--role (setting|other)');
|
|
94
|
+
if (!ROLES.includes(role))
|
|
95
|
+
throw new CliError(2, 'INVALID_ROLE', 'Use --role setting or other.');
|
|
96
|
+
return {
|
|
97
|
+
creative_id: need(options.creative, '--creative'),
|
|
98
|
+
[noun.idKey]: need(options[noun.option], noun.flag),
|
|
99
|
+
role,
|
|
100
|
+
name: (options.name || path.basename(media)).slice(0, 100),
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
async accept(receipt, save) {
|
|
104
|
+
const { creative_id, role, name } = receipt.metadata;
|
|
105
|
+
const elementId = receipt.metadata[noun.idKey];
|
|
106
|
+
if (!receipt.images_before) {
|
|
107
|
+
const before = imagesOf(
|
|
108
|
+
await elementImages(context.client, creative_id),
|
|
109
|
+
elementId,
|
|
110
|
+
);
|
|
111
|
+
receipt.images_before = imageIdsOf(before);
|
|
112
|
+
await save();
|
|
113
|
+
}
|
|
114
|
+
const after = imagesOf(
|
|
115
|
+
await context.client.post(
|
|
116
|
+
`${endpoint(creative_id, elementId)}/image-uploads`,
|
|
117
|
+
{ receipt_id: receipt.ready_receipt_id, role, name },
|
|
118
|
+
),
|
|
119
|
+
elementId,
|
|
120
|
+
);
|
|
121
|
+
const added = imageIdsOf(after).filter(
|
|
122
|
+
(imageId) => !receipt.images_before.includes(imageId),
|
|
123
|
+
);
|
|
124
|
+
if (added.length !== 1)
|
|
125
|
+
throw new CliError(
|
|
126
|
+
5,
|
|
127
|
+
'UPLOAD_IMAGE_UNKNOWN',
|
|
128
|
+
`The upload was accepted, but the image is not identifiable; read fuse ${nounKey} list.`,
|
|
129
|
+
);
|
|
130
|
+
receipt.result_id = added[0];
|
|
131
|
+
receipt.asset_id = after.library_asset.asset_id;
|
|
132
|
+
},
|
|
133
|
+
async result(receipt, file) {
|
|
134
|
+
const { creative_id, role } = receipt.metadata;
|
|
135
|
+
const elementId = receipt.metadata[noun.idKey];
|
|
136
|
+
const images = imagesOf(
|
|
137
|
+
await elementImages(context.client, creative_id),
|
|
138
|
+
elementId,
|
|
139
|
+
);
|
|
140
|
+
const landed =
|
|
141
|
+
role === 'setting'
|
|
142
|
+
? images.setting.versions.some(
|
|
143
|
+
(version) => version.id === receipt.result_id,
|
|
144
|
+
)
|
|
145
|
+
: images.other_images.some((image) => image.id === receipt.result_id);
|
|
146
|
+
if (!landed || images.library_asset?.asset_id !== receipt.asset_id)
|
|
147
|
+
throw new CliError(
|
|
148
|
+
4,
|
|
149
|
+
'UPLOAD_READBACK_DIFFERS',
|
|
150
|
+
`The uploaded image does not read back on the ${nounKey}.`,
|
|
151
|
+
);
|
|
152
|
+
return {
|
|
153
|
+
creative_id,
|
|
154
|
+
[noun.idKey]: elementId,
|
|
155
|
+
role,
|
|
156
|
+
asset_id: receipt.asset_id,
|
|
157
|
+
result_id: receipt.result_id,
|
|
158
|
+
current: images.setting.current_version_id === receipt.result_id,
|
|
159
|
+
receipt: file,
|
|
160
|
+
upload_session_id: receipt.session_id,
|
|
161
|
+
ready_receipt_id: receipt.ready_receipt_id,
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return async (action, positional, options, context) => {
|
|
167
|
+
if (action === 'list') {
|
|
168
|
+
const creativeId = need(options.creative, '--creative');
|
|
169
|
+
const [elements, read] = await Promise.all([
|
|
170
|
+
context.client.get(`/storyboards/${id(creativeId)}/story-elements`),
|
|
171
|
+
elementImages(context.client, creativeId),
|
|
172
|
+
]);
|
|
173
|
+
if (options.full) return { story_elements: elements, images: read };
|
|
174
|
+
return {
|
|
175
|
+
creative_id: creativeId,
|
|
176
|
+
[noun.readList]: noun.elementsOf(elements).map((row) => ({
|
|
177
|
+
[noun.idKey]: row.id,
|
|
178
|
+
name: row.name,
|
|
179
|
+
...(row.type ? { type: row.type } : {}),
|
|
180
|
+
...imagesSummary(imagesOf(read, row.id)),
|
|
181
|
+
})),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (action === 'upload')
|
|
185
|
+
return uploadWithReceipt(options, context, upload(options, context));
|
|
186
|
+
const creativeId = need(options.creative, '--creative');
|
|
187
|
+
const elementId = need(options[noun.option], noun.flag);
|
|
188
|
+
const base = endpoint(creativeId, elementId);
|
|
189
|
+
const done = (read, expected, code, message) => {
|
|
190
|
+
const images = imagesOf(read, elementId);
|
|
191
|
+
if (!expected(images)) throw new CliError(4, code, message);
|
|
192
|
+
return {
|
|
193
|
+
creative_id: creativeId,
|
|
194
|
+
[noun.idKey]: elementId,
|
|
195
|
+
...imagesSummary(images),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
if (action === 'adopt') {
|
|
199
|
+
const resultId = need(options.result, '--result');
|
|
200
|
+
return done(
|
|
201
|
+
await context.client.post(`${base}/setting-image`, {
|
|
202
|
+
result_id: resultId,
|
|
203
|
+
}),
|
|
204
|
+
(images) => images.setting.current_version_id === resultId,
|
|
205
|
+
'ADOPTION_READBACK_DIFFERS',
|
|
206
|
+
`The ${nounKey} does not read back the adopted ${noun.setting}.`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (action === 'link') {
|
|
210
|
+
const assetId = need(options.asset, '--asset');
|
|
211
|
+
return done(
|
|
212
|
+
await context.client.request('PUT', `${base}/library-asset`, {
|
|
213
|
+
asset_id: assetId,
|
|
214
|
+
}),
|
|
215
|
+
(images) => images.library_asset?.asset_id === assetId,
|
|
216
|
+
'LINK_READBACK_DIFFERS',
|
|
217
|
+
`The ${nounKey} does not read back the requested library Asset.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
// image delete (DEV-2176): the image lives on the element's library Asset,
|
|
221
|
+
// so the delete goes to the Asset. The element read before it names the
|
|
222
|
+
// Asset and proves the image is one of this element's images now; the read
|
|
223
|
+
// after it proves the image is gone.
|
|
224
|
+
if (action === 'image' && positional === 'delete') {
|
|
225
|
+
const resultId = need(options.result, '--result');
|
|
226
|
+
const before = imagesOf(
|
|
227
|
+
await elementImages(context.client, creativeId),
|
|
228
|
+
elementId,
|
|
229
|
+
);
|
|
230
|
+
if (!before.library_asset || !imageIdsOf(before).includes(resultId))
|
|
231
|
+
throw new CliError(
|
|
232
|
+
4,
|
|
233
|
+
'IMAGE_NOT_FOUND',
|
|
234
|
+
`The ${nounKey} has no image ${resultId}; read fuse ${nounKey} list.`,
|
|
235
|
+
);
|
|
236
|
+
await context.client.request(
|
|
237
|
+
'DELETE',
|
|
238
|
+
`/assets/${id(before.library_asset.asset_id, 'asset')}/images/${id(resultId, '--result')}`,
|
|
239
|
+
);
|
|
240
|
+
const after = done(
|
|
241
|
+
await elementImages(context.client, creativeId),
|
|
242
|
+
(images) => !imageIdsOf(images).includes(resultId),
|
|
243
|
+
'DELETE_READBACK_DIFFERS',
|
|
244
|
+
`The ${nounKey} still reads back image ${resultId}.`,
|
|
245
|
+
);
|
|
246
|
+
return { ...after, deleted_result_id: resultId };
|
|
247
|
+
}
|
|
248
|
+
throw new CliError(
|
|
249
|
+
2,
|
|
250
|
+
'UNKNOWN_COMMAND',
|
|
251
|
+
`Use ${nounKey} list, upload, adopt, link, or image delete.`,
|
|
252
|
+
);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const character = command('character');
|
|
257
|
+
export const art = command('art');
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import {
|
|
4
|
+
apiBase,
|
|
5
|
+
Client,
|
|
6
|
+
CliError,
|
|
7
|
+
loadCredential,
|
|
8
|
+
errorData,
|
|
9
|
+
project,
|
|
10
|
+
} from './core.mjs';
|
|
11
|
+
import { auth } from './auth.mjs';
|
|
12
|
+
import { models, videoSubmit, job, cancel } from './video.mjs';
|
|
13
|
+
import { download } from './download.mjs';
|
|
14
|
+
import { batch } from './batch.mjs';
|
|
15
|
+
import { assets } from './assets.mjs';
|
|
16
|
+
import { art, character } from './element-images.mjs';
|
|
17
|
+
import { creative, script, shot, take } from './creative.mjs';
|
|
18
|
+
import { prompt } from './prompt.mjs';
|
|
19
|
+
import { version } from './versions.mjs';
|
|
20
|
+
|
|
21
|
+
const stringFlags = [
|
|
22
|
+
'api-url',
|
|
23
|
+
'fields',
|
|
24
|
+
'device-name',
|
|
25
|
+
'timeout',
|
|
26
|
+
'stage',
|
|
27
|
+
'key',
|
|
28
|
+
'request-file',
|
|
29
|
+
'receipt',
|
|
30
|
+
'creative',
|
|
31
|
+
'generation-target',
|
|
32
|
+
'entry',
|
|
33
|
+
'shot',
|
|
34
|
+
'job',
|
|
35
|
+
'result',
|
|
36
|
+
'asset',
|
|
37
|
+
'out',
|
|
38
|
+
'state',
|
|
39
|
+
'limit',
|
|
40
|
+
'idempotency-key',
|
|
41
|
+
'file',
|
|
42
|
+
'version-id',
|
|
43
|
+
'episode',
|
|
44
|
+
'name',
|
|
45
|
+
'seed-file',
|
|
46
|
+
'page',
|
|
47
|
+
'cursor',
|
|
48
|
+
'type',
|
|
49
|
+
'content-type',
|
|
50
|
+
'pricing-key',
|
|
51
|
+
'character',
|
|
52
|
+
'element',
|
|
53
|
+
'kind',
|
|
54
|
+
'number',
|
|
55
|
+
'from',
|
|
56
|
+
'to',
|
|
57
|
+
];
|
|
58
|
+
const booleanFlags = [
|
|
59
|
+
'json',
|
|
60
|
+
'help',
|
|
61
|
+
'version',
|
|
62
|
+
'no-browser',
|
|
63
|
+
'full',
|
|
64
|
+
'resume',
|
|
65
|
+
'retry-rejected',
|
|
66
|
+
];
|
|
67
|
+
const help = `FUSE CLI 0.1.0 — Node 22; normal FUSE API, no GPU or database access.
|
|
68
|
+
|
|
69
|
+
fuse auth login [--no-browser] [--device-name NAME] # browser approval, independent session
|
|
70
|
+
fuse auth status | auth logout # read identity / revoke this session
|
|
71
|
+
fuse models list --stage video | models show --key EXACT_PRICING_KEY
|
|
72
|
+
fuse video submit --creative UUID --request-file request.json --receipt take.json
|
|
73
|
+
fuse video submit --receipt take.json --resume # reconcile exact saved body/key
|
|
74
|
+
fuse video submit --receipt take.json --resume --retry-rejected # explicitly retry an unaccepted rejection
|
|
75
|
+
fuse job show --receipt take.json
|
|
76
|
+
fuse job watch --receipt take.json --timeout 30m # read only; exiting does not cancel
|
|
77
|
+
fuse job cancel --receipt take.json | job cancel --job UUID # queued in FUSE (refunds the owner), or an open-source take already on its machine (no charge; the machine is told to stop)
|
|
78
|
+
fuse download take --receipt take.json --out take.mp4
|
|
79
|
+
fuse download take --creative UUID --shot N [--result UUID] --out take.mp4 # selected take unless --result
|
|
80
|
+
fuse download asset --asset UUID --result UUID --out asset.png
|
|
81
|
+
fuse batch plan comparison.json --out comparison.plan.json # read API; freeze inputs
|
|
82
|
+
fuse batch run comparison.plan.json --state ./run --timeout 2h
|
|
83
|
+
fuse batch status ./run | batch resume ./run | batch download ./run --out ./videos
|
|
84
|
+
fuse batch cancel ./run # stop the batch writer first; cancels queued Jobs and open-source takes on their machine, withdraws unsubmitted items
|
|
85
|
+
|
|
86
|
+
fuse asset list [--cursor UUID] [--type character|scene|prop]
|
|
87
|
+
fuse asset show --asset UUID [--out asset.json]
|
|
88
|
+
fuse asset upload --file previz.mp4 --content-type video/mp4 --request-file asset.json --receipt upload.json
|
|
89
|
+
fuse asset upload --receipt upload.json --resume # same Upload session, ready then Asset adoption
|
|
90
|
+
fuse asset link --asset UUID --request-file links.json # {creative_ids,asset_ids}
|
|
91
|
+
fuse asset unlink --asset UUID --creative UUID # remove from that Creative's collection; stays in the library
|
|
92
|
+
fuse asset canonical --asset UUID --result UUID # make that setting-image version current
|
|
93
|
+
fuse asset update --asset UUID --name NAME
|
|
94
|
+
fuse character list --creative UUID # each character's library Asset, 角色設定圖 versions and 其他圖片
|
|
95
|
+
fuse character upload --creative UUID --character UUID --role setting|other --file image.png --content-type image/png --receipt upload.json [--name NAME]
|
|
96
|
+
# setting: candidate 角色設定圖 version; other: a 其他圖片 on the Asset
|
|
97
|
+
fuse character upload --receipt upload.json --resume # same Upload session and acceptance
|
|
98
|
+
fuse character adopt --creative UUID --character UUID --result UUID # make that 角色設定圖 version current on the Asset
|
|
99
|
+
fuse character link --creative UUID --character UUID --asset UUID # point the character at another library character Asset
|
|
100
|
+
fuse character image delete --creative UUID --character UUID --result UUID # delete an uploaded image: a candidate 角色設定圖 version or a 其他圖片
|
|
101
|
+
fuse art list --creative UUID # each scene/prop's library Asset, 主圖 versions and 其他圖片
|
|
102
|
+
fuse art upload --creative UUID --element UUID --role setting|other --file image.png --content-type image/png --receipt upload.json [--name NAME]
|
|
103
|
+
# setting: candidate 主圖 version; other: a 其他圖片 on the Asset
|
|
104
|
+
fuse art upload --receipt upload.json --resume # same Upload session and acceptance
|
|
105
|
+
fuse art adopt --creative UUID --element UUID --result UUID # make that 主圖 version current on the Asset
|
|
106
|
+
fuse art link --creative UUID --element UUID --asset UUID # point the scene/prop at another library Asset of the same type
|
|
107
|
+
fuse art image delete --creative UUID --element UUID --result UUID # delete an uploaded image: a candidate 主圖 version or a 其他圖片
|
|
108
|
+
fuse creative create --seed-file seed.txt --entry idea|script --generation-target script|video --receipt creative.json
|
|
109
|
+
# idea: pitch_package then Episode scripts; script: imported verbatim (video adds storyboards)
|
|
110
|
+
fuse creative list --page 1 --limit 20 | creative show --creative UUID
|
|
111
|
+
fuse creative rename --creative UUID --name NAME
|
|
112
|
+
fuse script get --creative UUID --version-id UUID [--out script.json] # lists each Episode: number, episode_id, title, shot_count, editing
|
|
113
|
+
fuse script get --creative UUID --version-id UUID --episode N|UUID --out episode.json # one Episode as a script put body
|
|
114
|
+
fuse script put --creative UUID --version-id UUID --episode N|UUID --file episode.json # {shots,deleted_shot_ids}: one Episode
|
|
115
|
+
fuse script episode add --creative UUID --version-id UUID --name TITLE # appended as the last Episode
|
|
116
|
+
fuse script episode rename --creative UUID --version-id UUID --episode N|UUID --name TITLE
|
|
117
|
+
fuse script episode move --creative UUID --version-id UUID --episode N|UUID --to N # it becomes Episode N
|
|
118
|
+
fuse shot update --creative UUID --shot N --request-file shot.json
|
|
119
|
+
fuse shot move --creative UUID --shot N --request-file position.json # {position}: playback order
|
|
120
|
+
fuse version list --creative UUID --shot N [--stage i2v|t2i] # every version: draft / queued / running / succeeded / failed
|
|
121
|
+
fuse version new --creative UUID --shot N [--stage i2v] [--from 2] [--request-file draft.json]
|
|
122
|
+
# adds the next number as a draft; --from copies that version's draft
|
|
123
|
+
fuse version draft get --creative UUID --shot N --number 4 [--stage i2v] --out draft.json
|
|
124
|
+
fuse version draft set --creative UUID --shot N --number 4 [--stage i2v] --request-file draft.json
|
|
125
|
+
fuse version submit --creative UUID --shot N --number 4 [--stage i2v] --receipt v4.json [--request-file body.json]
|
|
126
|
+
fuse version submit --receipt v4.json --resume [--retry-rejected] # same receipt discipline as video submit
|
|
127
|
+
fuse version delete --creative UUID --shot N --number 4 [--stage i2v] # drafts only; numbers are never reused
|
|
128
|
+
fuse prompt optimize --request-file refine.json --receipt prompt.json
|
|
129
|
+
fuse prompt result --job REQUEST_UUID --out suggestion.json
|
|
130
|
+
fuse prompt adopt --creative UUID --shot N --number 4 --job REQUEST_UUID --request-file adopted-draft.json
|
|
131
|
+
fuse take select --creative UUID --shot N --result UUID
|
|
132
|
+
|
|
133
|
+
Creative create acceptance unknown is preserved; it is never blindly retried.
|
|
134
|
+
Script writes save one Episode and stop when someone else is editing it; optimization never auto-adopts or submits video.
|
|
135
|
+
Global: --api-url https://fuse.alchemint.xyz/api, --json, --fields field,field,
|
|
136
|
+
--full (explicit detailed read), --help, --version. Pipe output defaults to JSON.
|
|
137
|
+
Private request/receipt files retain the complete prompt and ordered references.
|
|
138
|
+
The receipt path must be new, or use --resume to reconcile that exact intent.
|
|
139
|
+
A plan contains finite items; each has key, creative_id, request_file and optional
|
|
140
|
+
idempotency_key/source/receipt_file. Requests are unmodified CreateJob video JSON.
|
|
141
|
+
Submission exit 0 means accepted, not completed. Exit 2 input, 3 auth, 4 rejected,
|
|
142
|
+
5 unknown transport/acceptance, 6 wait deadline/partial, 7 failed awaited Job.
|
|
143
|
+
Use FUSE_TOKEN only for a legitimately issued session; do not extract Web tokens.
|
|
144
|
+
`;
|
|
145
|
+
let options = {},
|
|
146
|
+
command = 'fuse',
|
|
147
|
+
json = !process.stdout.isTTY,
|
|
148
|
+
streaming = false;
|
|
149
|
+
const controller = new AbortController();
|
|
150
|
+
process.once('SIGINT', () => controller.abort());
|
|
151
|
+
process.once('SIGTERM', () => controller.abort());
|
|
152
|
+
function output(ok, data, error = null, event) {
|
|
153
|
+
const envelope = {
|
|
154
|
+
schema_version: 1,
|
|
155
|
+
command,
|
|
156
|
+
ok,
|
|
157
|
+
data: project(data, options.fields),
|
|
158
|
+
error,
|
|
159
|
+
...(event ? { event } : {}),
|
|
160
|
+
};
|
|
161
|
+
if (json) process.stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
162
|
+
else if (!ok)
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
`${command}: ${error.code}: ${error.message}${error.receipt ? `\nReceipt: ${error.receipt}` : ''}\n`,
|
|
165
|
+
);
|
|
166
|
+
else if (data?.help) process.stdout.write(data.help);
|
|
167
|
+
else
|
|
168
|
+
process.stdout.write(
|
|
169
|
+
`${command}: ${JSON.stringify(project(data, options.fields))}\n`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const parsed = parseArgs({
|
|
174
|
+
allowPositionals: true,
|
|
175
|
+
options: {
|
|
176
|
+
...Object.fromEntries(
|
|
177
|
+
stringFlags.map((key) => [key, { type: 'string' }]),
|
|
178
|
+
),
|
|
179
|
+
...Object.fromEntries(
|
|
180
|
+
booleanFlags.map((key) => [key, { type: 'boolean' }]),
|
|
181
|
+
),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
options = parsed.values;
|
|
185
|
+
json = options.json || !process.stdout.isTTY;
|
|
186
|
+
const [noun, action, positional] = parsed.positionals;
|
|
187
|
+
const positionalCount =
|
|
188
|
+
noun === 'batch' ||
|
|
189
|
+
(noun === 'version' && action === 'draft') ||
|
|
190
|
+
(noun === 'script' && action === 'episode') ||
|
|
191
|
+
((noun === 'character' || noun === 'art') && action === 'image')
|
|
192
|
+
? 3
|
|
193
|
+
: 2;
|
|
194
|
+
if (parsed.positionals.length > positionalCount)
|
|
195
|
+
throw new CliError(
|
|
196
|
+
2,
|
|
197
|
+
'INVALID_ARGUMENT',
|
|
198
|
+
'Unexpected positional argument. Run fuse --help for the command shape.',
|
|
199
|
+
);
|
|
200
|
+
command = [noun, action].filter(Boolean).join(' ') || 'fuse';
|
|
201
|
+
if (options.version)
|
|
202
|
+
output(true, { version: '0.1.0', node: process.version });
|
|
203
|
+
else if (options.help || !noun) output(true, { help });
|
|
204
|
+
else {
|
|
205
|
+
const base = apiBase(options['api-url']);
|
|
206
|
+
const client = new Client(
|
|
207
|
+
base,
|
|
208
|
+
await loadCredential(base),
|
|
209
|
+
controller.signal,
|
|
210
|
+
);
|
|
211
|
+
const context = {
|
|
212
|
+
client,
|
|
213
|
+
signal: controller.signal,
|
|
214
|
+
progress: (message) => process.stderr.write(`${message}\n`),
|
|
215
|
+
event: (event, data) => output(true, data, null, event),
|
|
216
|
+
};
|
|
217
|
+
let data;
|
|
218
|
+
if (noun === 'auth') data = await auth(action, options, context);
|
|
219
|
+
else if (noun === 'models' && ['list', 'show'].includes(action))
|
|
220
|
+
data = await models(action, options, context);
|
|
221
|
+
else if (noun === 'video' && action === 'submit')
|
|
222
|
+
data = await videoSubmit(options, context);
|
|
223
|
+
else if (noun === 'job' && action === 'cancel')
|
|
224
|
+
data = await cancel(options, context);
|
|
225
|
+
else if (noun === 'job' && ['show', 'watch'].includes(action)) {
|
|
226
|
+
streaming = action === 'watch';
|
|
227
|
+
data = await job(action, options, context);
|
|
228
|
+
} else if (noun === 'download')
|
|
229
|
+
data = await download(action, options, context);
|
|
230
|
+
else if (noun === 'asset') data = await assets(action, options, context);
|
|
231
|
+
else if (noun === 'character' || noun === 'art') {
|
|
232
|
+
command = [noun, action, action === 'image' ? positional : null]
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join(' ');
|
|
235
|
+
data = await (noun === 'character' ? character : art)(
|
|
236
|
+
action,
|
|
237
|
+
positional,
|
|
238
|
+
options,
|
|
239
|
+
context,
|
|
240
|
+
);
|
|
241
|
+
} else if (noun === 'creative')
|
|
242
|
+
data = await creative(action, options, context);
|
|
243
|
+
else if (noun === 'script') {
|
|
244
|
+
command = [noun, action, action === 'episode' ? positional : null]
|
|
245
|
+
.filter(Boolean)
|
|
246
|
+
.join(' ');
|
|
247
|
+
data = await script(action, positional, options, context);
|
|
248
|
+
} else if (noun === 'shot') data = await shot(action, options, context);
|
|
249
|
+
else if (noun === 'prompt') data = await prompt(action, options, context);
|
|
250
|
+
else if (noun === 'version') {
|
|
251
|
+
command = [noun, action, action === 'draft' ? positional : null]
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.join(' ');
|
|
254
|
+
data = await version(action, positional, options, context);
|
|
255
|
+
} else if (noun === 'take') data = await take(action, options, context);
|
|
256
|
+
else if (noun === 'batch')
|
|
257
|
+
data = await batch(action, positional, options, context);
|
|
258
|
+
else
|
|
259
|
+
throw new CliError(
|
|
260
|
+
2,
|
|
261
|
+
'UNKNOWN_COMMAND',
|
|
262
|
+
'Unknown command. Run fuse --help.',
|
|
263
|
+
);
|
|
264
|
+
output(true, data, null, streaming ? 'complete' : undefined);
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error.code?.startsWith('ERR_PARSE_ARGS'))
|
|
268
|
+
error = new CliError(2, 'INVALID_ARGUMENT', error.message);
|
|
269
|
+
output(
|
|
270
|
+
false,
|
|
271
|
+
error.data || null,
|
|
272
|
+
errorData(error),
|
|
273
|
+
streaming ? 'error' : undefined,
|
|
274
|
+
);
|
|
275
|
+
process.exitCode = error.exit || 2;
|
|
276
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
CliError,
|
|
5
|
+
need,
|
|
6
|
+
subject,
|
|
7
|
+
sameSubject,
|
|
8
|
+
readJson,
|
|
9
|
+
writeJson,
|
|
10
|
+
exists,
|
|
11
|
+
digest,
|
|
12
|
+
withWriter,
|
|
13
|
+
errorData,
|
|
14
|
+
} from './core.mjs';
|
|
15
|
+
|
|
16
|
+
/** Persist one named intent. This is not a POST retry policy: only the explicit
|
|
17
|
+
* prompt optimization owner supplies its existing idempotency contract. */
|
|
18
|
+
export async function recordedMutation(
|
|
19
|
+
kind,
|
|
20
|
+
endpoint,
|
|
21
|
+
body,
|
|
22
|
+
options,
|
|
23
|
+
context,
|
|
24
|
+
summarize,
|
|
25
|
+
idempotent = false,
|
|
26
|
+
) {
|
|
27
|
+
const file = path.resolve(need(options.receipt, '--receipt'));
|
|
28
|
+
return withWriter(`${file}.lock`, async () => {
|
|
29
|
+
const owner = await subject(context.client);
|
|
30
|
+
let receipt;
|
|
31
|
+
if (await exists(file)) {
|
|
32
|
+
receipt = await readJson(file);
|
|
33
|
+
sameSubject(receipt, owner);
|
|
34
|
+
if (
|
|
35
|
+
!options.resume ||
|
|
36
|
+
receipt.schema_version !== 1 ||
|
|
37
|
+
receipt.kind !== kind ||
|
|
38
|
+
receipt.endpoint !== endpoint ||
|
|
39
|
+
digest(receipt.request) !== receipt.request_sha256
|
|
40
|
+
)
|
|
41
|
+
throw new CliError(
|
|
42
|
+
2,
|
|
43
|
+
'RECEIPT_EXISTS',
|
|
44
|
+
'Use the original receipt to inspect this intent; it cannot be replaced.',
|
|
45
|
+
);
|
|
46
|
+
if (receipt.accepted) return { ...receipt.accepted, receipt: file };
|
|
47
|
+
if (!idempotent || receipt.state === 'rejected')
|
|
48
|
+
throw new CliError(
|
|
49
|
+
5,
|
|
50
|
+
'MUTATION_UNRESOLVED',
|
|
51
|
+
'This non-replayable intent needs reconciliation through its existing owner read. It was not resubmitted.',
|
|
52
|
+
{ receipt: file },
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
if (options.resume)
|
|
56
|
+
throw new CliError(
|
|
57
|
+
2,
|
|
58
|
+
'RECEIPT_NOT_FOUND',
|
|
59
|
+
'Resume requires the existing receipt file.',
|
|
60
|
+
);
|
|
61
|
+
receipt = {
|
|
62
|
+
schema_version: 1,
|
|
63
|
+
kind,
|
|
64
|
+
...owner,
|
|
65
|
+
endpoint,
|
|
66
|
+
request: body,
|
|
67
|
+
request_sha256: digest(body),
|
|
68
|
+
state: 'prepared',
|
|
69
|
+
created_at: new Date().toISOString(),
|
|
70
|
+
...(idempotent
|
|
71
|
+
? { idempotency_key: options['idempotency-key'] || randomUUID() }
|
|
72
|
+
: {}),
|
|
73
|
+
};
|
|
74
|
+
await writeJson(file, receipt, true);
|
|
75
|
+
}
|
|
76
|
+
receipt.state = 'submitting';
|
|
77
|
+
await writeJson(file, receipt);
|
|
78
|
+
try {
|
|
79
|
+
const result = await context.client.post(
|
|
80
|
+
receipt.endpoint,
|
|
81
|
+
receipt.request,
|
|
82
|
+
idempotent
|
|
83
|
+
? { headers: { 'X-Idempotency-Key': receipt.idempotency_key } }
|
|
84
|
+
: undefined,
|
|
85
|
+
);
|
|
86
|
+
const accepted = summarize(result);
|
|
87
|
+
if (
|
|
88
|
+
Object.entries(accepted).some(
|
|
89
|
+
([key, value]) =>
|
|
90
|
+
key.endsWith('_id') &&
|
|
91
|
+
!(
|
|
92
|
+
(typeof value === 'string' && value.length) ||
|
|
93
|
+
(Number.isInteger(value) && value > 0)
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
throw new CliError(
|
|
98
|
+
5,
|
|
99
|
+
'RESPONSE_UNKNOWN',
|
|
100
|
+
'The response did not identify the accepted object. Reconcile this saved intent.',
|
|
101
|
+
);
|
|
102
|
+
receipt.accepted = accepted;
|
|
103
|
+
receipt.state = 'accepted';
|
|
104
|
+
await writeJson(file, receipt);
|
|
105
|
+
return { ...receipt.accepted, receipt: file };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!(error instanceof CliError))
|
|
108
|
+
error = new CliError(
|
|
109
|
+
5,
|
|
110
|
+
'RECEIPT_UNKNOWN',
|
|
111
|
+
'Acceptance could not be recorded. Keep this intent and reconcile it.',
|
|
112
|
+
);
|
|
113
|
+
receipt.state =
|
|
114
|
+
error.exit === 5
|
|
115
|
+
? 'unknown'
|
|
116
|
+
: error.exit === 3
|
|
117
|
+
? 'auth_required'
|
|
118
|
+
: 'rejected';
|
|
119
|
+
receipt.error = errorData(error);
|
|
120
|
+
await writeJson(file, receipt);
|
|
121
|
+
error.receipt = file;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|