@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
package/src/creative.mjs
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { CliError, need, id, readJson, writeJson, digest } from './core.mjs';
|
|
4
|
+
import { recordedMutation } from './mutations.mjs';
|
|
5
|
+
|
|
6
|
+
const GENERATION_TARGETS = ['script', 'video'];
|
|
7
|
+
const ENTRIES = ['idea', 'script'];
|
|
8
|
+
|
|
9
|
+
/** Each Shot is one video: its takes and the currently selected take. */
|
|
10
|
+
export const creativeSummary = (row) => ({
|
|
11
|
+
creative_id: row.id,
|
|
12
|
+
title: row.title,
|
|
13
|
+
status: row.status,
|
|
14
|
+
versions: row.versions.map((version) => ({
|
|
15
|
+
version_id: version.id,
|
|
16
|
+
script_content_revision: version.script_content_revision,
|
|
17
|
+
})),
|
|
18
|
+
shots: row.shots.map((shot) => ({
|
|
19
|
+
shot_id: shot.id,
|
|
20
|
+
version_id: shot.version_id,
|
|
21
|
+
sequence: shot.sequence,
|
|
22
|
+
duration_sec: shot.duration_sec,
|
|
23
|
+
selected_result_id: shot.selected_result_id,
|
|
24
|
+
takes: shot.results
|
|
25
|
+
.filter((take) => take.stage === 'i2v')
|
|
26
|
+
.map((take) => ({
|
|
27
|
+
result_id: take.id,
|
|
28
|
+
job_id: take.job_id,
|
|
29
|
+
status: take.status,
|
|
30
|
+
model: take.model,
|
|
31
|
+
})),
|
|
32
|
+
})),
|
|
33
|
+
});
|
|
34
|
+
const creativeCardSummary = (row) => ({
|
|
35
|
+
creative_id: row.id,
|
|
36
|
+
title: row.title,
|
|
37
|
+
status: row.status,
|
|
38
|
+
shot_count: row.shot_count,
|
|
39
|
+
});
|
|
40
|
+
/**
|
|
41
|
+
* First generation: an idea starts with pitch_package and then writes each Episode's script;
|
|
42
|
+
* a pasted script is imported verbatim. Video continues to each Episode's storyboard.
|
|
43
|
+
*/
|
|
44
|
+
const initialActivitySummary = (activity) => ({
|
|
45
|
+
activity_id: activity.activity_id,
|
|
46
|
+
status: activity.status,
|
|
47
|
+
phase: activity.phase,
|
|
48
|
+
generation_entry: activity.generation_entry,
|
|
49
|
+
generation_target: activity.generation_target,
|
|
50
|
+
completed_milestones: activity.completed_milestones,
|
|
51
|
+
episodes: activity.episodes.map((episode) => ({
|
|
52
|
+
episode_id: episode.episode_id,
|
|
53
|
+
title: episode.title,
|
|
54
|
+
script_status: episode.script_status,
|
|
55
|
+
script_accepted: episode.script_accepted,
|
|
56
|
+
storyboard_status: episode.storyboard_status,
|
|
57
|
+
storyboard_accepted: episode.storyboard_accepted,
|
|
58
|
+
})),
|
|
59
|
+
});
|
|
60
|
+
export async function creative(action, options, context) {
|
|
61
|
+
if (action === 'create') {
|
|
62
|
+
if (options['seed-file'] && options['request-file'])
|
|
63
|
+
throw new CliError(
|
|
64
|
+
2,
|
|
65
|
+
'AMBIGUOUS_INPUT',
|
|
66
|
+
'Choose --seed-file or --request-file.',
|
|
67
|
+
);
|
|
68
|
+
if (
|
|
69
|
+
options['request-file'] &&
|
|
70
|
+
(options['generation-target'] || options.entry)
|
|
71
|
+
)
|
|
72
|
+
throw new CliError(
|
|
73
|
+
2,
|
|
74
|
+
'AMBIGUOUS_INPUT',
|
|
75
|
+
'The CreateStoryboard request file already carries generation_target and entry.',
|
|
76
|
+
);
|
|
77
|
+
let body;
|
|
78
|
+
if (options['seed-file'] && !options.resume) {
|
|
79
|
+
const target = need(
|
|
80
|
+
options['generation-target'],
|
|
81
|
+
'--generation-target script|video',
|
|
82
|
+
);
|
|
83
|
+
if (!GENERATION_TARGETS.includes(target))
|
|
84
|
+
throw new CliError(
|
|
85
|
+
2,
|
|
86
|
+
'INVALID_GENERATION_TARGET',
|
|
87
|
+
'Use --generation-target script (stops after the Episode scripts) or video (continues to the storyboards).',
|
|
88
|
+
);
|
|
89
|
+
const entry = need(options.entry, '--entry idea|script');
|
|
90
|
+
if (!ENTRIES.includes(entry))
|
|
91
|
+
throw new CliError(
|
|
92
|
+
2,
|
|
93
|
+
'INVALID_ENTRY',
|
|
94
|
+
'Use --entry idea (the seed is developed into a story) or script (the seed is a written script imported verbatim).',
|
|
95
|
+
);
|
|
96
|
+
body = {
|
|
97
|
+
input_text: await readFile(options['seed-file'], 'utf8'),
|
|
98
|
+
generation_target: target,
|
|
99
|
+
entry,
|
|
100
|
+
};
|
|
101
|
+
} else if (!options.resume)
|
|
102
|
+
body = await readJson(
|
|
103
|
+
need(options['request-file'], '--seed-file or --request-file'),
|
|
104
|
+
);
|
|
105
|
+
const result = await recordedMutation(
|
|
106
|
+
'creative_create',
|
|
107
|
+
'/storyboards',
|
|
108
|
+
body,
|
|
109
|
+
options,
|
|
110
|
+
context,
|
|
111
|
+
(row) => ({ creative_id: row.id, activity_id: row.activity_id }),
|
|
112
|
+
);
|
|
113
|
+
const activity = await context.client.get(
|
|
114
|
+
`/generation/activities/initial/${id(result.creative_id)}`,
|
|
115
|
+
);
|
|
116
|
+
return {
|
|
117
|
+
...result,
|
|
118
|
+
initial_activity: activity ? initialActivitySummary(activity) : null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (action === 'list') {
|
|
122
|
+
if (options.cursor)
|
|
123
|
+
throw new CliError(
|
|
124
|
+
2,
|
|
125
|
+
'UNSUPPORTED_PAGINATION',
|
|
126
|
+
'The Creative summary API has no cursor; use --page and --limit for local projection.',
|
|
127
|
+
);
|
|
128
|
+
const rows = await context.client.get('/storyboards');
|
|
129
|
+
const limit = Number(options.limit || 20),
|
|
130
|
+
page = Number(options.page || 1);
|
|
131
|
+
if (
|
|
132
|
+
!Number.isInteger(limit) ||
|
|
133
|
+
limit < 1 ||
|
|
134
|
+
!Number.isInteger(page) ||
|
|
135
|
+
page < 1
|
|
136
|
+
)
|
|
137
|
+
throw new CliError(
|
|
138
|
+
2,
|
|
139
|
+
'INVALID_PAGE',
|
|
140
|
+
'page and limit must be positive integers.',
|
|
141
|
+
);
|
|
142
|
+
const selected = rows.slice((page - 1) * limit, page * limit);
|
|
143
|
+
return {
|
|
144
|
+
items: options.full ? selected : selected.map(creativeCardSummary),
|
|
145
|
+
page,
|
|
146
|
+
limit,
|
|
147
|
+
total: rows.length,
|
|
148
|
+
pagination: 'client_projection_of_server_summary',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const endpoint = `/storyboards/${id(options.creative, '--creative')}`;
|
|
152
|
+
if (action === 'rename')
|
|
153
|
+
await context.client.request('PATCH', endpoint, {
|
|
154
|
+
title: need(options.name, '--name'),
|
|
155
|
+
});
|
|
156
|
+
else if (action !== 'show')
|
|
157
|
+
throw new CliError(
|
|
158
|
+
2,
|
|
159
|
+
'UNKNOWN_COMMAND',
|
|
160
|
+
'Use creative create, list, show, or rename.',
|
|
161
|
+
);
|
|
162
|
+
const row = await context.client.get(endpoint);
|
|
163
|
+
if (options.out) {
|
|
164
|
+
await writeJson(options.out, row, true);
|
|
165
|
+
return { creative_id: row.id, file: path.resolve(options.out) };
|
|
166
|
+
}
|
|
167
|
+
return options.full ? row : creativeSummary(row);
|
|
168
|
+
}
|
|
169
|
+
/** 第幾集是 Script 文件裡的順序,從 1 起,與網頁的「第 N 集」相同;editing 是正持有那一集分集鎖的人。 */
|
|
170
|
+
const episodeRows = (view) =>
|
|
171
|
+
view.document.episodes.map((episode, index) => ({
|
|
172
|
+
number: index + 1,
|
|
173
|
+
episode_id: episode.id,
|
|
174
|
+
title: episode.title,
|
|
175
|
+
shot_count: episode.shots.length,
|
|
176
|
+
editing:
|
|
177
|
+
view.episode_locks.find((lock) => lock.episode_id === episode.id)
|
|
178
|
+
?.holder ?? null,
|
|
179
|
+
}));
|
|
180
|
+
/** --episode 收集數或 episode_id;集數依這一次讀回的順序解析,寫入時送的是 episode_id。 */
|
|
181
|
+
function episodeOf(view, value) {
|
|
182
|
+
const rows = episodeRows(view);
|
|
183
|
+
const row = /^\d+$/.test(value)
|
|
184
|
+
? rows[Number(value) - 1]
|
|
185
|
+
: rows.find((item) => item.episode_id === value);
|
|
186
|
+
if (!row)
|
|
187
|
+
throw new CliError(
|
|
188
|
+
2,
|
|
189
|
+
'EPISODE_NOT_FOUND',
|
|
190
|
+
`This Script has ${rows.length} Episode(s): use --episode 1-${rows.length} or an episode_id from script get.`,
|
|
191
|
+
);
|
|
192
|
+
return row;
|
|
193
|
+
}
|
|
194
|
+
export async function script(action, sub, options, context) {
|
|
195
|
+
const endpoint = `/storyboards/${id(options.creative, '--creative')}/versions/${id(options['version-id'], '--version-id')}/script`;
|
|
196
|
+
const located = (view, row) => ({
|
|
197
|
+
creative_id: options.creative,
|
|
198
|
+
version_id: options['version-id'],
|
|
199
|
+
content_revision: view.content_revision,
|
|
200
|
+
...row,
|
|
201
|
+
});
|
|
202
|
+
if (action === 'episode') {
|
|
203
|
+
// 集指令(DEV-2097):新增與調整順序不改任何一集的內容;改集名時那一集正由別人編輯就停下,回報是誰。
|
|
204
|
+
let view, episodeId;
|
|
205
|
+
if (sub === 'add') {
|
|
206
|
+
view = await context.client.post(`${endpoint}/episodes`, {
|
|
207
|
+
title: need(options.name, '--name'),
|
|
208
|
+
});
|
|
209
|
+
// 新增的一集接在最後;回應是同一個交易裡讀回的 Script。
|
|
210
|
+
episodeId = view.document.episodes.at(-1).id;
|
|
211
|
+
} else if (sub === 'rename' || sub === 'move') {
|
|
212
|
+
let body;
|
|
213
|
+
if (sub === 'rename') body = { title: need(options.name, '--name') };
|
|
214
|
+
else {
|
|
215
|
+
const to = need(options.to, '--to N');
|
|
216
|
+
if (!/^[1-9]\d*$/.test(to))
|
|
217
|
+
throw new CliError(
|
|
218
|
+
2,
|
|
219
|
+
'INVALID_EPISODE_NUMBER',
|
|
220
|
+
'Use --to with the Episode number it becomes, from 1.',
|
|
221
|
+
);
|
|
222
|
+
body = { position: Number(to) - 1 };
|
|
223
|
+
}
|
|
224
|
+
const episode = need(options.episode, '--episode N|UUID');
|
|
225
|
+
episodeId = episodeOf(
|
|
226
|
+
await context.client.get(endpoint),
|
|
227
|
+
episode,
|
|
228
|
+
).episode_id;
|
|
229
|
+
const target = `${endpoint}/episodes/${episodeId}`;
|
|
230
|
+
view =
|
|
231
|
+
sub === 'rename'
|
|
232
|
+
? await context.client.request('PATCH', target, body)
|
|
233
|
+
: await context.client.post(`${target}/move`, body);
|
|
234
|
+
} else
|
|
235
|
+
throw new CliError(
|
|
236
|
+
2,
|
|
237
|
+
'UNKNOWN_COMMAND',
|
|
238
|
+
'Use script episode add, rename, or move.',
|
|
239
|
+
);
|
|
240
|
+
return {
|
|
241
|
+
...located(view, episodeOf(view, episodeId)),
|
|
242
|
+
episodes: episodeRows(view),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
if (action === 'put') {
|
|
246
|
+
// DEV-2096:一次存一集。別人正在編輯那一集時不寫入,回報是誰;CLI 是一次性的寫入,
|
|
247
|
+
// 存完就放掉那一集的鎖,不留給之後。
|
|
248
|
+
const target = need(options.episode, '--episode N|UUID');
|
|
249
|
+
const body = await readJson(
|
|
250
|
+
need(options.file, '--file (episode write body)'),
|
|
251
|
+
);
|
|
252
|
+
const { episode_id: episodeId } = episodeOf(
|
|
253
|
+
await context.client.get(endpoint),
|
|
254
|
+
target,
|
|
255
|
+
);
|
|
256
|
+
const episode = `${endpoint}/episodes/${episodeId}`;
|
|
257
|
+
const saved = await context.client.request('PUT', episode, body);
|
|
258
|
+
await context.client.request('DELETE', `${episode}/lock`);
|
|
259
|
+
const current = await context.client.get(endpoint);
|
|
260
|
+
if (options.out) await writeJson(options.out, current, true);
|
|
261
|
+
return options.full
|
|
262
|
+
? { saved, current }
|
|
263
|
+
: {
|
|
264
|
+
...located(current, episodeOf(current, episodeId)),
|
|
265
|
+
document_sha256: digest(current.document),
|
|
266
|
+
...(options.out ? { file: path.resolve(options.out) } : {}),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (action !== 'get')
|
|
270
|
+
throw new CliError(
|
|
271
|
+
2,
|
|
272
|
+
'UNKNOWN_COMMAND',
|
|
273
|
+
'Use script get, put, or episode add|rename|move.',
|
|
274
|
+
);
|
|
275
|
+
const current = await context.client.get(endpoint);
|
|
276
|
+
if (options.episode) {
|
|
277
|
+
const row = episodeOf(current, options.episode);
|
|
278
|
+
const { shots } = current.document.episodes[row.number - 1];
|
|
279
|
+
if (options.out) {
|
|
280
|
+
// 匯出成 script put 可直接送回的那一集內容。
|
|
281
|
+
await writeJson(options.out, { shots, deleted_shot_ids: [] }, true);
|
|
282
|
+
return { ...located(current, row), file: path.resolve(options.out) };
|
|
283
|
+
}
|
|
284
|
+
return options.full
|
|
285
|
+
? { ...located(current, row), shots }
|
|
286
|
+
: located(current, row);
|
|
287
|
+
}
|
|
288
|
+
if (options.out) {
|
|
289
|
+
await writeJson(options.out, current, true);
|
|
290
|
+
return {
|
|
291
|
+
file: path.resolve(options.out),
|
|
292
|
+
content_revision: current.content_revision,
|
|
293
|
+
episodes: episodeRows(current),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return options.full
|
|
297
|
+
? current
|
|
298
|
+
: {
|
|
299
|
+
creative_id: options.creative,
|
|
300
|
+
version_id: options['version-id'],
|
|
301
|
+
content_revision: current.content_revision,
|
|
302
|
+
document_sha256: digest(current.document),
|
|
303
|
+
episodes: episodeRows(current),
|
|
304
|
+
note: 'Use --out to export the full Script, or --episode N --out to export one Episode as a script put body.',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
export async function shot(action, options, context) {
|
|
308
|
+
const creativeId = need(options.creative, '--creative (readback owner)');
|
|
309
|
+
const creativePath = `/storyboards/${id(creativeId)}`;
|
|
310
|
+
let endpoint;
|
|
311
|
+
if (action === 'update') endpoint = `/shots/${id(options.shot, '--shot')}`;
|
|
312
|
+
else if (action === 'move')
|
|
313
|
+
endpoint = `/shots/${id(options.shot, '--shot')}/position`;
|
|
314
|
+
else throw new CliError(2, 'UNKNOWN_COMMAND', 'Use shot update or move.');
|
|
315
|
+
const before = await context.client.get(creativePath);
|
|
316
|
+
if (!before.shots.some((row) => row.id === Number(options.shot)))
|
|
317
|
+
throw new CliError(
|
|
318
|
+
2,
|
|
319
|
+
'TARGET_MISMATCH',
|
|
320
|
+
'The Shot is not in this Creative.',
|
|
321
|
+
);
|
|
322
|
+
await context.client.request(
|
|
323
|
+
'PATCH',
|
|
324
|
+
endpoint,
|
|
325
|
+
await readJson(need(options['request-file'], '--request-file')),
|
|
326
|
+
);
|
|
327
|
+
const current = await context.client.get(creativePath);
|
|
328
|
+
return options.full
|
|
329
|
+
? current
|
|
330
|
+
: {
|
|
331
|
+
creative_id: creativeId,
|
|
332
|
+
shots: current.shots.map((row) => ({
|
|
333
|
+
shot_id: row.id,
|
|
334
|
+
version_id: row.version_id,
|
|
335
|
+
sequence: row.sequence,
|
|
336
|
+
})),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
export async function take(action, options, context) {
|
|
340
|
+
if (action !== 'select')
|
|
341
|
+
throw new CliError(2, 'UNKNOWN_COMMAND', 'Use take select.');
|
|
342
|
+
const creativePath = `/storyboards/${id(options.creative, '--creative')}`;
|
|
343
|
+
const shotId = Number(need(options.shot, '--shot'));
|
|
344
|
+
const row = await context.client.get(creativePath);
|
|
345
|
+
const shot = row.shots.find((item) => item.id === shotId);
|
|
346
|
+
if (
|
|
347
|
+
!shot?.results.some(
|
|
348
|
+
(item) =>
|
|
349
|
+
item.stage === 'i2v' && item.id === need(options.result, '--result'),
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
throw new CliError(
|
|
353
|
+
2,
|
|
354
|
+
'RESULT_MISMATCH',
|
|
355
|
+
'Choose a video Result already in the specified Creative/Shot.',
|
|
356
|
+
);
|
|
357
|
+
await context.client.request(
|
|
358
|
+
'PATCH',
|
|
359
|
+
`/shots/${id(shotId, '--shot')}/select`,
|
|
360
|
+
{
|
|
361
|
+
result_id: options.result,
|
|
362
|
+
},
|
|
363
|
+
);
|
|
364
|
+
const current = await context.client.get(creativePath);
|
|
365
|
+
const selected = current.shots.find(
|
|
366
|
+
(item) => item.id === shotId,
|
|
367
|
+
).selected_result_id;
|
|
368
|
+
if (selected !== options.result)
|
|
369
|
+
throw new CliError(
|
|
370
|
+
4,
|
|
371
|
+
'SELECTION_READBACK_DIFFERS',
|
|
372
|
+
'The current selected Take differs from the requested Result. Inspect the Creative.',
|
|
373
|
+
);
|
|
374
|
+
return {
|
|
375
|
+
creative_id: options.creative,
|
|
376
|
+
shot_id: shotId,
|
|
377
|
+
selected_result_id: selected,
|
|
378
|
+
};
|
|
379
|
+
}
|
package/src/download.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir, link, unlink, open } from 'node:fs/promises';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { Transform, Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
CliError,
|
|
9
|
+
need,
|
|
10
|
+
id,
|
|
11
|
+
readJson,
|
|
12
|
+
exists,
|
|
13
|
+
sameSubject,
|
|
14
|
+
subject,
|
|
15
|
+
} from './core.mjs';
|
|
16
|
+
|
|
17
|
+
export async function fileDigest(file) {
|
|
18
|
+
const hash = createHash('sha256');
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
for await (const chunk of createReadStream(file)) {
|
|
21
|
+
hash.update(chunk);
|
|
22
|
+
bytes += chunk.length;
|
|
23
|
+
}
|
|
24
|
+
return { bytes, sha256: hash.digest('hex') };
|
|
25
|
+
}
|
|
26
|
+
export async function downloadUrl(url, file, signal) {
|
|
27
|
+
file = path.resolve(file);
|
|
28
|
+
if (await exists(file))
|
|
29
|
+
throw new CliError(2, 'FILE_EXISTS', `Output already exists: ${file}`);
|
|
30
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
31
|
+
const partial = `${file}.${randomUUID()}.partial`;
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await fetch(url, {
|
|
35
|
+
headers: { 'Accept-Encoding': 'identity' },
|
|
36
|
+
signal: signal
|
|
37
|
+
? AbortSignal.any([signal, AbortSignal.timeout(15 * 60000)])
|
|
38
|
+
: AbortSignal.timeout(15 * 60000),
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
throw new CliError(
|
|
42
|
+
5,
|
|
43
|
+
'DOWNLOAD_INTERRUPTED',
|
|
44
|
+
'Could not fetch the media bytes. Retry through the original FUSE download endpoint.',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (!response.ok || !response.body)
|
|
48
|
+
throw new CliError(
|
|
49
|
+
4,
|
|
50
|
+
'MEDIA_HTTP_ERROR',
|
|
51
|
+
`Media download returned HTTP ${response.status}. Obtain a fresh URL through the same FUSE result.`,
|
|
52
|
+
{ http_status: response.status },
|
|
53
|
+
);
|
|
54
|
+
const hash = createHash('sha256');
|
|
55
|
+
let bytes = 0;
|
|
56
|
+
try {
|
|
57
|
+
await pipeline(
|
|
58
|
+
Readable.fromWeb(response.body),
|
|
59
|
+
new Transform({
|
|
60
|
+
transform(chunk, encoding, callback) {
|
|
61
|
+
bytes += chunk.length;
|
|
62
|
+
hash.update(chunk);
|
|
63
|
+
callback(null, chunk);
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
createWriteStream(partial, { flags: 'wx', mode: 0o600 }),
|
|
67
|
+
);
|
|
68
|
+
const declared = response.headers.get('content-length');
|
|
69
|
+
if (bytes === 0 || (declared !== null && bytes !== Number(declared)))
|
|
70
|
+
throw new Error('Incomplete media body');
|
|
71
|
+
const handle = await open(partial, 'r+');
|
|
72
|
+
try {
|
|
73
|
+
await handle.sync();
|
|
74
|
+
} finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
await link(partial, file);
|
|
78
|
+
await unlink(partial);
|
|
79
|
+
return {
|
|
80
|
+
file,
|
|
81
|
+
bytes,
|
|
82
|
+
sha256: hash.digest('hex'),
|
|
83
|
+
content_type: response.headers.get('content-type'),
|
|
84
|
+
downloaded: true,
|
|
85
|
+
};
|
|
86
|
+
} catch {
|
|
87
|
+
throw new CliError(
|
|
88
|
+
5,
|
|
89
|
+
'DOWNLOAD_INTERRUPTED',
|
|
90
|
+
`Media did not complete; partial file: ${partial}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export async function download(kind, options, context) {
|
|
95
|
+
let creative = options.creative,
|
|
96
|
+
shot = options.shot,
|
|
97
|
+
result = options.result;
|
|
98
|
+
if (options.receipt) {
|
|
99
|
+
const receipt = await readJson(options.receipt);
|
|
100
|
+
sameSubject(receipt, await subject(context.client));
|
|
101
|
+
creative = receipt.creative_id;
|
|
102
|
+
shot = receipt.request.shot_id;
|
|
103
|
+
result = receipt.accepted?.result_id;
|
|
104
|
+
}
|
|
105
|
+
let endpoint;
|
|
106
|
+
// A take is the exact Result when one is named (receipt or --result);
|
|
107
|
+
// otherwise it is the Shot's currently selected take.
|
|
108
|
+
if (kind === 'take')
|
|
109
|
+
endpoint = `/storyboards/${id(creative, '--creative')}/download/shots/${id(shot, '--shot')}/take${result ? `?result_id=${id(result, '--result')}` : ''}`;
|
|
110
|
+
else if (kind === 'asset')
|
|
111
|
+
endpoint = `/assets/${id(options.asset, '--asset')}/results/${id(options.result, '--result')}/download`;
|
|
112
|
+
else throw new CliError(2, 'UNKNOWN_COMMAND', 'Use download take or asset.');
|
|
113
|
+
const { url } = await context.client.get(endpoint);
|
|
114
|
+
return {
|
|
115
|
+
kind,
|
|
116
|
+
creative_id: creative,
|
|
117
|
+
shot_id: shot ? Number(shot) : undefined,
|
|
118
|
+
result_id: result,
|
|
119
|
+
...(await downloadUrl(url, need(options.out, '--out'), context.signal)),
|
|
120
|
+
};
|
|
121
|
+
}
|