@giveitsmaller/sdk 0.4.0 → 0.7.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/dist/_audit.js +67 -0
- package/dist/builder.d.ts +406 -0
- package/dist/builder.js +706 -0
- package/dist/client.d.ts +96 -2
- package/dist/client.js +968 -33
- package/dist/credentials.d.ts +61 -0
- package/dist/credentials.js +200 -0
- package/dist/ergonomic/preset_resolver.d.ts +75 -0
- package/dist/ergonomic/preset_resolver.js +568 -0
- package/dist/ergonomic/presets/_translate.d.ts +11 -0
- package/dist/ergonomic/presets/_translate.js +35 -0
- package/dist/ergonomic/presets/audio_compress.d.ts +16 -0
- package/dist/ergonomic/presets/audio_compress.js +45 -0
- package/dist/ergonomic/presets/document_epub_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_epub_compress.js +34 -0
- package/dist/ergonomic/presets/document_odf_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_odf_compress.js +34 -0
- package/dist/ergonomic/presets/document_office_compress.d.ts +16 -0
- package/dist/ergonomic/presets/document_office_compress.js +40 -0
- package/dist/ergonomic/presets/document_pdf_compress.d.ts +14 -0
- package/dist/ergonomic/presets/document_pdf_compress.js +35 -0
- package/dist/ergonomic/presets/image_compress.d.ts +43 -0
- package/dist/ergonomic/presets/image_compress.js +95 -0
- package/dist/ergonomic/presets/index.d.ts +77 -0
- package/dist/ergonomic/presets/index.js +216 -0
- package/dist/ergonomic/presets/video_compress.d.ts +30 -0
- package/dist/ergonomic/presets/video_compress.js +83 -0
- package/dist/errors.d.ts +251 -1
- package/dist/errors.js +268 -0
- package/dist/generated/sdk_spec/enums.d.ts +195 -0
- package/dist/generated/sdk_spec/enums.js +127 -0
- package/dist/generated/sdk_spec/errors.d.ts +16 -0
- package/dist/generated/sdk_spec/errors.js +473 -0
- package/dist/generated/sdk_spec/index.d.ts +4 -0
- package/dist/generated/sdk_spec/index.js +7 -0
- package/dist/generated/sdk_spec/presets.d.ts +6 -0
- package/dist/generated/sdk_spec/presets.js +157 -0
- package/dist/generated/sdk_spec/version.d.ts +3 -0
- package/dist/generated/sdk_spec/version.js +6 -0
- package/dist/gisl.d.ts +112 -0
- package/dist/gisl.js +266 -0
- package/dist/index.d.ts +17 -7
- package/dist/index.js +33 -3
- package/dist/merge.d.ts +142 -0
- package/dist/merge.js +411 -0
- package/dist/sse.d.ts +20 -1
- package/dist/sse.js +62 -3
- package/dist/types.d.ts +144 -14
- package/dist/types.js +18 -0
- package/package.json +2 -2
package/dist/merge.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge-compose layer for the SDK ergonomic surface (T3 / cuecCmb5).
|
|
3
|
+
*
|
|
4
|
+
* `client.merge(...assets, options?)` returns a `MergeBuilder`. The builder
|
|
5
|
+
* separates WHAT (the asset set) from ORDER (the timeline):
|
|
6
|
+
*
|
|
7
|
+
* - `merge(a, b, c)` declares the asset set — each unique input is uploaded
|
|
8
|
+
* ONCE per run, even if referenced multiple times in the sequence.
|
|
9
|
+
* - `.sequence(...refs)` defines the play order. References may repeat
|
|
10
|
+
* freely; entries may be bare asset refs or `clip(ref, opts)` objects
|
|
11
|
+
* carrying per-position options.
|
|
12
|
+
* - No `.sequence(...)` => play in declared order, no transitions.
|
|
13
|
+
*
|
|
14
|
+
* Wire-truth boundaries (lowering.md §sequences):
|
|
15
|
+
* - Video merge per-input options: `transition`, `crossfadeDuration` only.
|
|
16
|
+
* - Audio merge per-input options: `transition`, `crossfadeDuration`,
|
|
17
|
+
* `gapDuration` only.
|
|
18
|
+
* - Image merge has NO per-input options today — `clip(ref)` is reuse/order
|
|
19
|
+
* only. Per-position transitions on image merges throw locally as
|
|
20
|
+
* `GislPerInputOptionsNotSupportedError`.
|
|
21
|
+
* - No per-clip `trimStart`/`trimEnd` today (contracts ticket iZzn5QrS
|
|
22
|
+
* tracks the fix). Workaround: pre-trim each clip via a chained
|
|
23
|
+
* `compress(file, trimStart, trimEnd)`.
|
|
24
|
+
*
|
|
25
|
+
* Local validation runs BEFORE any upload — undeclared refs and unused
|
|
26
|
+
* assets both fail fast so the caller saves bandwidth on typo'd composes.
|
|
27
|
+
*/
|
|
28
|
+
import { uploadSource } from './types.js';
|
|
29
|
+
import { GislConfigError, GislPerInputOptionsNotSupportedError, GislTimeoutError, GislUndeclaredAssetError, GislUnusedAssetError, } from './errors.js';
|
|
30
|
+
import { _checkAborted, _consumeSseToTerminal, _parseMaxWait, _pollToTerminal, _projectResult, } from './builder.js';
|
|
31
|
+
/**
|
|
32
|
+
* Construct a path-asset. Bare-string arguments to `merge(...)` are
|
|
33
|
+
* implicitly wrapped via this helper.
|
|
34
|
+
*/
|
|
35
|
+
export function asset(path) {
|
|
36
|
+
return { type: 'path', path };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Wrap an already-uploaded file_id as a merge asset. Use this when the
|
|
40
|
+
* SAME logical file should be referenced from multiple merge runs with
|
|
41
|
+
* guaranteed-single-upload semantics.
|
|
42
|
+
*/
|
|
43
|
+
export function handle(fileId) {
|
|
44
|
+
return { type: 'handle', fileId };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Construct a clip entry for `.sequence(...)`. The asset MUST already
|
|
48
|
+
* be in the merge's declared asset set.
|
|
49
|
+
*/
|
|
50
|
+
export function clip(ref, options = {}) {
|
|
51
|
+
return { type: 'clip', asset: ref, options };
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// MergeBuilder
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
/**
|
|
57
|
+
* Captures the (declared assets, options) for a merge. `.sequence(...)`
|
|
58
|
+
* pins the play order; without it, the declared order is used as-is
|
|
59
|
+
* with no per-input options.
|
|
60
|
+
*
|
|
61
|
+
* Local validation runs at `.run()`/`.submit()` time (BEFORE any upload)
|
|
62
|
+
* and throws one of `GislUndeclaredAssetError`, `GislUnusedAssetError`,
|
|
63
|
+
* or `GislPerInputOptionsNotSupportedError` if the compose is invalid.
|
|
64
|
+
*/
|
|
65
|
+
export class MergeBuilder {
|
|
66
|
+
client;
|
|
67
|
+
assets;
|
|
68
|
+
opOptions;
|
|
69
|
+
sequenceEntries = null;
|
|
70
|
+
constructor(client, assets, opOptions) {
|
|
71
|
+
this.client = client;
|
|
72
|
+
this.assets = assets;
|
|
73
|
+
this.opOptions = opOptions;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Pin the merge play order. Each entry must reference an asset that
|
|
77
|
+
* was declared in the parent `merge(...)` call. Repeats are allowed
|
|
78
|
+
* and deduped on upload (one upload per unique declared asset).
|
|
79
|
+
*/
|
|
80
|
+
sequence(...entries) {
|
|
81
|
+
this.sequenceEntries = entries;
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
async run(options) {
|
|
85
|
+
const deadline = Date.now() + _parseMaxWait(options.maxWait);
|
|
86
|
+
const signal = options.signal;
|
|
87
|
+
const onProgress = options.onProgress;
|
|
88
|
+
const useSSE = options.useSSE ?? true;
|
|
89
|
+
// 1. Validate locally BEFORE any upload.
|
|
90
|
+
const plan = this.planSequence();
|
|
91
|
+
// 2. Upload each unique asset exactly ONCE. Pass the deadline so the
|
|
92
|
+
// upload loop can abort mid-batch on a slow connection.
|
|
93
|
+
const uploadedByAssetId = await this.uploadUniqueAssets(plan.uniqueAssets, {
|
|
94
|
+
signal,
|
|
95
|
+
onProgress,
|
|
96
|
+
deadline,
|
|
97
|
+
});
|
|
98
|
+
_checkAborted(signal);
|
|
99
|
+
if (Date.now() >= deadline) {
|
|
100
|
+
throw new GislTimeoutError(`Upload(s) completed but maxWait elapsed before merge workflow could be created`);
|
|
101
|
+
}
|
|
102
|
+
// 3. Build the merge JobDefinitionPayload (multi-input).
|
|
103
|
+
const payload = this.buildPayload(plan, uploadedByAssetId);
|
|
104
|
+
const created = await this.client.createWorkflow(payload);
|
|
105
|
+
_checkAborted(signal);
|
|
106
|
+
// 4. Wait to terminal status.
|
|
107
|
+
const finalStatus = await this.awaitTerminal({
|
|
108
|
+
workflowId: created.workflowId,
|
|
109
|
+
deadline,
|
|
110
|
+
signal,
|
|
111
|
+
onProgress,
|
|
112
|
+
useSSE,
|
|
113
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
114
|
+
});
|
|
115
|
+
// 5. Fetch downloads + project.
|
|
116
|
+
if (Date.now() >= deadline) {
|
|
117
|
+
throw new GislTimeoutError(`Merge workflow ${created.workflowId} reached terminal status but maxWait elapsed before downloads could be fetched`);
|
|
118
|
+
}
|
|
119
|
+
const downloads = await this.client.getWorkflowDownloads(created.workflowId);
|
|
120
|
+
return _projectResult(finalStatus, downloads.downloads, this.opOptionsForResolved());
|
|
121
|
+
}
|
|
122
|
+
async submit(options) {
|
|
123
|
+
const plan = this.planSequence();
|
|
124
|
+
const uploadedByAssetId = await this.uploadUniqueAssets(plan.uniqueAssets, {});
|
|
125
|
+
const payload = this.buildPayload(plan, uploadedByAssetId);
|
|
126
|
+
payload.callback_url = options.webhook;
|
|
127
|
+
const created = await this.client.createWorkflow(payload);
|
|
128
|
+
const handle = {
|
|
129
|
+
workflowId: created.workflowId,
|
|
130
|
+
...(created.webhookSecret != null ? { webhookSecret: created.webhookSecret } : {}),
|
|
131
|
+
};
|
|
132
|
+
return handle;
|
|
133
|
+
}
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/**
|
|
136
|
+
* Resolve the declared assets + sequence (or fall back to declared order),
|
|
137
|
+
* dedupe by identity, and run the local validators. The returned plan
|
|
138
|
+
* carries the SEQUENCE (positional entries) + the UNIQUE assets to upload.
|
|
139
|
+
*/
|
|
140
|
+
planSequence() {
|
|
141
|
+
const declaredIds = this.assets.map(assetIdentity);
|
|
142
|
+
const declaredSet = new Map();
|
|
143
|
+
for (let i = 0; i < this.assets.length; i += 1) {
|
|
144
|
+
const id = declaredIds[i];
|
|
145
|
+
if (!declaredSet.has(id))
|
|
146
|
+
declaredSet.set(id, this.assets[i]);
|
|
147
|
+
}
|
|
148
|
+
// Use the explicit sequence if set; otherwise the declared order as-is.
|
|
149
|
+
const rawEntries = this.sequenceEntries ?? this.assets.map((a) => a);
|
|
150
|
+
// Validate per-entry: undeclared ref + image-merge-clip-with-opts.
|
|
151
|
+
const mediaKind = this.inferMediaKind();
|
|
152
|
+
const positions = [];
|
|
153
|
+
const refIds = new Set();
|
|
154
|
+
for (const entry of rawEntries) {
|
|
155
|
+
const isClip = entry !== null && typeof entry === 'object' && 'type' in entry && entry.type === 'clip';
|
|
156
|
+
const assetRef = isClip ? entry.asset : entry;
|
|
157
|
+
const id = assetIdentity(assetRef);
|
|
158
|
+
if (!declaredSet.has(id)) {
|
|
159
|
+
throw new GislUndeclaredAssetError(id, Array.from(declaredSet.keys()));
|
|
160
|
+
}
|
|
161
|
+
refIds.add(id);
|
|
162
|
+
if (isClip) {
|
|
163
|
+
const opts = entry.options;
|
|
164
|
+
const hasOpts = opts.transition !== undefined ||
|
|
165
|
+
opts.crossfadeDuration !== undefined ||
|
|
166
|
+
opts.gapDuration !== undefined;
|
|
167
|
+
if (hasOpts && mediaKind === 'image') {
|
|
168
|
+
throw new GislPerInputOptionsNotSupportedError('image');
|
|
169
|
+
}
|
|
170
|
+
positions.push({ assetId: id, options: opts });
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
positions.push({ assetId: id, options: {} });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Unused-asset check.
|
|
177
|
+
if (this.sequenceEntries !== null && this.opOptions.allowUnusedAssets !== true) {
|
|
178
|
+
const unused = Array.from(declaredSet.keys()).filter((id) => !refIds.has(id));
|
|
179
|
+
if (unused.length > 0)
|
|
180
|
+
throw new GislUnusedAssetError(unused);
|
|
181
|
+
}
|
|
182
|
+
// Codex r1 medium edb1bb641d81 — when an explicit sequence is set,
|
|
183
|
+
// restrict the upload set to only the referenced assets. This prevents
|
|
184
|
+
// wasted uploads of declared-but-unsequenced assets (e.g. when the
|
|
185
|
+
// user passes allowUnusedAssets: true).
|
|
186
|
+
const uploadSet = this.sequenceEntries === null
|
|
187
|
+
? declaredSet
|
|
188
|
+
: new Map(Array.from(declaredSet.entries()).filter(([id]) => refIds.has(id)));
|
|
189
|
+
// Codex r1 medium 5c86b67c979b — enforce merge schema input bounds
|
|
190
|
+
// (min_inputs: 2, max_inputs: 10 per generated/typescript/operations/merge.ts).
|
|
191
|
+
// Validate sequence position count, NOT unique-asset count: the merge job
|
|
192
|
+
// sends N inputs where N = position count (repeats included).
|
|
193
|
+
if (positions.length < 2) {
|
|
194
|
+
throw new GislConfigError(`merge requires at least 2 inputs (got ${positions.length}). Declare more assets or check the sequence.`);
|
|
195
|
+
}
|
|
196
|
+
if (positions.length > 10) {
|
|
197
|
+
throw new GislConfigError(`merge accepts at most 10 inputs (got ${positions.length}). Reduce the sequence or split the merge.`);
|
|
198
|
+
}
|
|
199
|
+
return { mediaKind, positions, uniqueAssets: uploadSet };
|
|
200
|
+
}
|
|
201
|
+
inferMediaKind() {
|
|
202
|
+
if (this.opOptions.mediaKind !== undefined)
|
|
203
|
+
return this.opOptions.mediaKind;
|
|
204
|
+
const first = this.assets[0];
|
|
205
|
+
if (first === undefined)
|
|
206
|
+
return 'video';
|
|
207
|
+
if (first.type === 'path' && typeof first.path === 'string') {
|
|
208
|
+
const lower = first.path.toLowerCase();
|
|
209
|
+
if (/\.(jpe?g|png|webp|avif|gif|heic|tiff?)$/.test(lower))
|
|
210
|
+
return 'image';
|
|
211
|
+
if (/\.(mp3|wav|flac|aac|ogg|m4a)$/.test(lower))
|
|
212
|
+
return 'audio';
|
|
213
|
+
return 'video';
|
|
214
|
+
}
|
|
215
|
+
if (first.type === 'path' && first.path instanceof Blob) {
|
|
216
|
+
if (first.path.type.startsWith('image/'))
|
|
217
|
+
return 'image';
|
|
218
|
+
if (first.path.type.startsWith('audio/'))
|
|
219
|
+
return 'audio';
|
|
220
|
+
return 'video';
|
|
221
|
+
}
|
|
222
|
+
return 'video';
|
|
223
|
+
}
|
|
224
|
+
async uploadUniqueAssets(uniqueAssets, opts) {
|
|
225
|
+
const uploaded = new Map();
|
|
226
|
+
for (const [id, a] of uniqueAssets) {
|
|
227
|
+
// Codex r1 medium 797b4113431f — check the deadline between uploads
|
|
228
|
+
// so a multi-file merge doesn't keep uploading past `maxWait`.
|
|
229
|
+
if (opts.deadline !== undefined && Date.now() >= opts.deadline) {
|
|
230
|
+
throw new GislTimeoutError(`maxWait elapsed mid-upload (after ${uploaded.size} of ${uniqueAssets.size} merge assets)`);
|
|
231
|
+
}
|
|
232
|
+
if (a.type === 'handle') {
|
|
233
|
+
uploaded.set(id, a.fileId);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const uploadOpts = {};
|
|
237
|
+
if (opts.signal !== undefined)
|
|
238
|
+
uploadOpts.signal = opts.signal;
|
|
239
|
+
if (opts.onProgress !== undefined) {
|
|
240
|
+
uploadOpts.onProgress = (uploadedBytes, totalBytes) => {
|
|
241
|
+
opts.onProgress?.({ phase: 'upload', uploadedBytes, totalBytes });
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const resp = await this.client.uploadFile(a.path, uploadOpts);
|
|
245
|
+
uploaded.set(id, resp.fileId);
|
|
246
|
+
}
|
|
247
|
+
return uploaded;
|
|
248
|
+
}
|
|
249
|
+
buildPayload(plan, uploadedByAssetId) {
|
|
250
|
+
const inputs = plan.positions.map((pos) => {
|
|
251
|
+
const fileId = uploadedByAssetId.get(pos.assetId);
|
|
252
|
+
if (fileId === undefined) {
|
|
253
|
+
// Defensive — planSequence should have rejected this.
|
|
254
|
+
throw new Error(`Asset '${pos.assetId}' was never uploaded — internal builder bug`);
|
|
255
|
+
}
|
|
256
|
+
// Codex r1 HIGH 502c6bf232c2 — per_input_options goes on EACH
|
|
257
|
+
// JobInputV2Payload (per-input entry), NOT on operations[0].options.
|
|
258
|
+
// Skip emission for image merges (planSequence already rejects opts
|
|
259
|
+
// on image-merge clips). Project per ClipOptions per media kind
|
|
260
|
+
// (codex r1 medium 128404fa16a9 — gapDuration is audio-only).
|
|
261
|
+
const wireOpts = plan.mediaKind === 'image'
|
|
262
|
+
? {}
|
|
263
|
+
: wirePerInputOptions(pos.options, plan.mediaKind);
|
|
264
|
+
const input = { source: uploadSource(fileId) };
|
|
265
|
+
if (Object.keys(wireOpts).length > 0) {
|
|
266
|
+
input.per_input_options = wireOpts;
|
|
267
|
+
}
|
|
268
|
+
return input;
|
|
269
|
+
});
|
|
270
|
+
// Merge-level options (excluding the SDK-side mediaKind/allowUnusedAssets).
|
|
271
|
+
const mergeOpts = wireMergeOptions(this.opOptions, plan.mediaKind);
|
|
272
|
+
const job = {
|
|
273
|
+
id: 'merge',
|
|
274
|
+
inputs,
|
|
275
|
+
operations: [{ type: 'merge', options: mergeOpts }],
|
|
276
|
+
};
|
|
277
|
+
return { jobs: [job] };
|
|
278
|
+
}
|
|
279
|
+
opOptionsForResolved() {
|
|
280
|
+
// Strip the SDK-only fields before exposing on resolvedOptions.applied.
|
|
281
|
+
const { mediaKind: _m, allowUnusedAssets: _a, ...rest } = this.opOptions;
|
|
282
|
+
void _m;
|
|
283
|
+
void _a;
|
|
284
|
+
return { ...rest };
|
|
285
|
+
}
|
|
286
|
+
async awaitTerminal(args) {
|
|
287
|
+
if (args.useSSE) {
|
|
288
|
+
try {
|
|
289
|
+
return await _consumeSseToTerminal(this.client, args);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
if (err instanceof GislTimeoutError)
|
|
293
|
+
throw err;
|
|
294
|
+
if (err instanceof DOMException && err.name === 'AbortError')
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return await _pollToTerminal(this.client, args);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Per-Blob identity tokens — referential dedupe via WeakMap. Two distinct
|
|
303
|
+
* Blob objects with the same size + MIME would otherwise hash to the
|
|
304
|
+
* SAME identity (silent data loss; code-reviewer P1 conf 8). Reference
|
|
305
|
+
* identity guarantees same-Blob = same-upload and different-Blob = two
|
|
306
|
+
* uploads, irrespective of content sniffing.
|
|
307
|
+
*/
|
|
308
|
+
const _blobTokens = new WeakMap();
|
|
309
|
+
let _blobCounter = 0;
|
|
310
|
+
/**
|
|
311
|
+
* Asset identity for dedupe. Handles use their fileId; paths use a
|
|
312
|
+
* trim+trailing-separator-strip normalised string (NOT case-folded —
|
|
313
|
+
* case-insensitive dedupe would silently merge `A.mp4` and `a.mp4` on a
|
|
314
|
+
* case-sensitive filesystem; code-reviewer P1 conf 7). Blobs use
|
|
315
|
+
* referential identity via a WeakMap-backed token. Bare-string path
|
|
316
|
+
* dedupe is best-effort — use `handle()` for guaranteed reuse.
|
|
317
|
+
*/
|
|
318
|
+
function assetIdentity(a) {
|
|
319
|
+
if (a.type === 'handle')
|
|
320
|
+
return `handle:${a.fileId}`;
|
|
321
|
+
if (a.path instanceof Blob) {
|
|
322
|
+
let token = _blobTokens.get(a.path);
|
|
323
|
+
if (token === undefined) {
|
|
324
|
+
_blobCounter += 1;
|
|
325
|
+
token = `${_blobCounter}`;
|
|
326
|
+
_blobTokens.set(a.path, token);
|
|
327
|
+
}
|
|
328
|
+
return `blob:${token}`;
|
|
329
|
+
}
|
|
330
|
+
// Codex r2 medium bb500566a683 — dedupe by the EXACT caller-provided
|
|
331
|
+
// string. Previous trim+trailing-slash-strip would collapse
|
|
332
|
+
// `'clip.mp4'` and `'clip.mp4 '` into one upload while uploadFile later
|
|
333
|
+
// received the original string. Exact-string dedupe = upload identity
|
|
334
|
+
// matches dedupe identity. Best-effort = "two identical strings dedupe;
|
|
335
|
+
// anything else is a separate upload" — predictable.
|
|
336
|
+
return `path:${a.path}`;
|
|
337
|
+
}
|
|
338
|
+
function wireMergeOptions(opts, mediaKind) {
|
|
339
|
+
const out = {};
|
|
340
|
+
if (opts.transition !== undefined)
|
|
341
|
+
out.transition = opts.transition;
|
|
342
|
+
if (opts.crossfadeDuration !== undefined)
|
|
343
|
+
out.crossfade_duration = opts.crossfadeDuration;
|
|
344
|
+
// Codex r2 medium ab2422e56ea0 — merge-level `gap_duration` is on
|
|
345
|
+
// MergeAudioOptions only (not MergeVideoOptions or MergeImageOptions).
|
|
346
|
+
// Drop it for non-audio merges instead of shipping an invalid payload.
|
|
347
|
+
if (opts.gapDuration !== undefined && mediaKind === 'audio')
|
|
348
|
+
out.gap_duration = opts.gapDuration;
|
|
349
|
+
if (opts.normalizeAudio !== undefined)
|
|
350
|
+
out.normalize_audio = opts.normalizeAudio;
|
|
351
|
+
if (opts.codec !== undefined)
|
|
352
|
+
out.codec = opts.codec;
|
|
353
|
+
if (opts.crf !== undefined)
|
|
354
|
+
out.crf = opts.crf;
|
|
355
|
+
if (opts.preset !== undefined)
|
|
356
|
+
out.preset = opts.preset;
|
|
357
|
+
if (opts.targetSize !== undefined) {
|
|
358
|
+
out.target_size_bytes = typeof opts.targetSize === 'number'
|
|
359
|
+
? opts.targetSize
|
|
360
|
+
: parseSizeString(opts.targetSize);
|
|
361
|
+
out.encoding_mode = 'target_size';
|
|
362
|
+
}
|
|
363
|
+
if (opts.transitionDuration !== undefined)
|
|
364
|
+
out.transition_duration = opts.transitionDuration;
|
|
365
|
+
if (opts.fps !== undefined)
|
|
366
|
+
out.fps = opts.fps;
|
|
367
|
+
if (opts.durationPerImage !== undefined)
|
|
368
|
+
out.duration_per_image = opts.durationPerImage;
|
|
369
|
+
if (opts.loopCount !== undefined)
|
|
370
|
+
out.loop_count = opts.loopCount;
|
|
371
|
+
if (opts.output !== undefined)
|
|
372
|
+
out.output_type = opts.output;
|
|
373
|
+
if (opts.outputType !== undefined)
|
|
374
|
+
out.output_type = opts.outputType;
|
|
375
|
+
if (opts.videoFormat !== undefined)
|
|
376
|
+
out.video_format = opts.videoFormat;
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Project a ClipOptions into the wire-shape per_input_options object.
|
|
381
|
+
* Codex r1 medium 128404fa16a9 — `gap_duration` is on AUDIO per-input
|
|
382
|
+
* only (`MergeAudioPerInputOptions`), NOT video. Splitting by mediaKind
|
|
383
|
+
* here keeps the wire payload honest and prevents the server from
|
|
384
|
+
* silently rejecting/ignoring an out-of-spec field.
|
|
385
|
+
*/
|
|
386
|
+
function wirePerInputOptions(opts, mediaKind) {
|
|
387
|
+
const out = {};
|
|
388
|
+
if (opts.transition !== undefined)
|
|
389
|
+
out.transition = opts.transition;
|
|
390
|
+
if (opts.crossfadeDuration !== undefined)
|
|
391
|
+
out.crossfade_duration = opts.crossfadeDuration;
|
|
392
|
+
if (opts.gapDuration !== undefined && mediaKind === 'audio') {
|
|
393
|
+
out.gap_duration = opts.gapDuration;
|
|
394
|
+
}
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
function parseSizeString(s) {
|
|
398
|
+
const m = /^(\d+(?:\.\d+)?)\s*(KB|MB|GB|B)?$/i.exec(s.trim());
|
|
399
|
+
if (m === null)
|
|
400
|
+
throw new TypeError(`Invalid targetSize string '${s}'`);
|
|
401
|
+
const n = Number(m[1]);
|
|
402
|
+
const unit = (m[2] ?? 'B').toUpperCase();
|
|
403
|
+
switch (unit) {
|
|
404
|
+
case 'B': return Math.round(n);
|
|
405
|
+
case 'KB': return Math.round(n * 1_000);
|
|
406
|
+
case 'MB': return Math.round(n * 1_000_000);
|
|
407
|
+
case 'GB': return Math.round(n * 1_000_000_000);
|
|
408
|
+
/* istanbul ignore next */
|
|
409
|
+
default: throw new TypeError(`Unknown size unit '${unit}'`);
|
|
410
|
+
}
|
|
411
|
+
}
|
package/dist/sse.d.ts
CHANGED
|
@@ -7,5 +7,24 @@ import type { GislSseEvent } from './types.js';
|
|
|
7
7
|
* - Multi-line `data:` fields (concatenated with newlines)
|
|
8
8
|
* - Comment lines (`:` prefix) used as keep-alives
|
|
9
9
|
* - `retry:` field (ignored, SDK manages its own reconnection)
|
|
10
|
+
*
|
|
11
|
+
* `opts.signal` (optional): when it aborts, the underlying body reader is
|
|
12
|
+
* cancelled. This is the ONLY way to promptly stop a stream parked on a
|
|
13
|
+
* quiet socket: `reader.read()` is suspended, so the generator's `finally`
|
|
14
|
+
* cannot run until that read settles — calling `reader.cancel()` from the
|
|
15
|
+
* abort listener settles it (`{ done: true }`) and runs the stream's cancel
|
|
16
|
+
* algorithm, freeing the connection. (MDN/TC39: an async generator's
|
|
17
|
+
* `return()` is itself unreachable while suspended at `await`; cancellation
|
|
18
|
+
* must be driven externally via an AbortSignal.) `GislClient.streamEvents`
|
|
19
|
+
* owns the controller and wires `return()`/`throw()` → `abort()`.
|
|
20
|
+
*
|
|
21
|
+
* By-design limitation: when called WITHOUT `opts.signal`, there is no
|
|
22
|
+
* cancellation path while `reader.read()` is suspended — a consumer that
|
|
23
|
+
* `break`s / `gen.return()`s on a quiet socket stays stuck until the
|
|
24
|
+
* server sends data or closes (an inherent JS async-generator constraint,
|
|
25
|
+
* not a defect). Pass `opts.signal`, or prefer `GislClient.streamEvents`
|
|
26
|
+
* (which always wires one), whenever early termination must be prompt.
|
|
10
27
|
*/
|
|
11
|
-
export declare function parseSseStream(response: Response
|
|
28
|
+
export declare function parseSseStream(response: Response, opts?: {
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}): AsyncGenerator<GislSseEvent>;
|
package/dist/sse.js
CHANGED
|
@@ -6,13 +6,51 @@
|
|
|
6
6
|
* - Multi-line `data:` fields (concatenated with newlines)
|
|
7
7
|
* - Comment lines (`:` prefix) used as keep-alives
|
|
8
8
|
* - `retry:` field (ignored, SDK manages its own reconnection)
|
|
9
|
+
*
|
|
10
|
+
* `opts.signal` (optional): when it aborts, the underlying body reader is
|
|
11
|
+
* cancelled. This is the ONLY way to promptly stop a stream parked on a
|
|
12
|
+
* quiet socket: `reader.read()` is suspended, so the generator's `finally`
|
|
13
|
+
* cannot run until that read settles — calling `reader.cancel()` from the
|
|
14
|
+
* abort listener settles it (`{ done: true }`) and runs the stream's cancel
|
|
15
|
+
* algorithm, freeing the connection. (MDN/TC39: an async generator's
|
|
16
|
+
* `return()` is itself unreachable while suspended at `await`; cancellation
|
|
17
|
+
* must be driven externally via an AbortSignal.) `GislClient.streamEvents`
|
|
18
|
+
* owns the controller and wires `return()`/`throw()` → `abort()`.
|
|
19
|
+
*
|
|
20
|
+
* By-design limitation: when called WITHOUT `opts.signal`, there is no
|
|
21
|
+
* cancellation path while `reader.read()` is suspended — a consumer that
|
|
22
|
+
* `break`s / `gen.return()`s on a quiet socket stays stuck until the
|
|
23
|
+
* server sends data or closes (an inherent JS async-generator constraint,
|
|
24
|
+
* not a defect). Pass `opts.signal`, or prefer `GislClient.streamEvents`
|
|
25
|
+
* (which always wires one), whenever early termination must be prompt.
|
|
9
26
|
*/
|
|
10
|
-
export async function* parseSseStream(response) {
|
|
27
|
+
export async function* parseSseStream(response, opts = {}) {
|
|
11
28
|
const body = response.body;
|
|
12
29
|
if (!body) {
|
|
13
30
|
return;
|
|
14
31
|
}
|
|
32
|
+
const signal = opts.signal;
|
|
33
|
+
if (signal?.aborted) {
|
|
34
|
+
// Pre-aborted: the body is still unlocked (no reader yet), so cancel it
|
|
35
|
+
// directly to free the connection, then yield nothing.
|
|
36
|
+
await body.cancel().catch(() => { });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
15
39
|
const reader = body.getReader();
|
|
40
|
+
// `reader.cancel()` is valid while the reader holds the lock (unlike
|
|
41
|
+
// `body.cancel()`, which throws "Cannot cancel a locked stream"). It
|
|
42
|
+
// settles the in-flight `reader.read()` with `{ done: true }` and runs
|
|
43
|
+
// the stream's cancel algorithm. We track `aborted` so the post-loop
|
|
44
|
+
// trailing flush below does NOT emit a partial, never-terminated event
|
|
45
|
+
// once the consumer has abandoned the stream (the cancelled read looks
|
|
46
|
+
// exactly like a clean EOF — `{ done: true }` — but a buffered
|
|
47
|
+
// unterminated `data:` run after an abort is garbage, not an event).
|
|
48
|
+
let aborted = false;
|
|
49
|
+
const onAbort = () => {
|
|
50
|
+
aborted = true;
|
|
51
|
+
void reader.cancel().catch(() => { });
|
|
52
|
+
};
|
|
53
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
16
54
|
const decoder = new TextDecoder();
|
|
17
55
|
let buffer = '';
|
|
18
56
|
let eventType = '';
|
|
@@ -28,6 +66,12 @@ export async function* parseSseStream(response) {
|
|
|
28
66
|
// Keep the last (potentially incomplete) line in the buffer
|
|
29
67
|
buffer = lines.pop() ?? '';
|
|
30
68
|
for (const line of lines) {
|
|
69
|
+
// If a single read delivered multiple complete events and the
|
|
70
|
+
// consumer aborted after receiving an earlier one (we were
|
|
71
|
+
// suspended at `yield`), do NOT keep emitting the remaining
|
|
72
|
+
// buffered events on resume — stop processing this batch.
|
|
73
|
+
if (aborted)
|
|
74
|
+
break;
|
|
31
75
|
if (line === '') {
|
|
32
76
|
// Empty line = end of event
|
|
33
77
|
if (dataLines.length > 0) {
|
|
@@ -73,8 +117,12 @@ export async function* parseSseStream(response) {
|
|
|
73
117
|
}
|
|
74
118
|
}
|
|
75
119
|
}
|
|
76
|
-
// Flush any remaining buffered event
|
|
77
|
-
|
|
120
|
+
// Flush any remaining buffered event — but ONLY on a genuine
|
|
121
|
+
// end-of-stream (server closed without a final blank line). After an
|
|
122
|
+
// abort, the cancelled read also surfaces as `{ done: true }`, yet a
|
|
123
|
+
// partial unterminated `data:` buffer is not a real event and must
|
|
124
|
+
// not be yielded once the consumer has abandoned the stream.
|
|
125
|
+
if (!aborted && dataLines.length > 0) {
|
|
78
126
|
const rawData = dataLines.join('\n');
|
|
79
127
|
let parsed;
|
|
80
128
|
try {
|
|
@@ -90,6 +138,17 @@ export async function* parseSseStream(response) {
|
|
|
90
138
|
}
|
|
91
139
|
}
|
|
92
140
|
finally {
|
|
141
|
+
signal?.removeEventListener('abort', onAbort);
|
|
142
|
+
// Cancel the body so the underlying HTTP connection is released on
|
|
143
|
+
// EVERY exit path (early `return()`/abort AND normal completion) — a
|
|
144
|
+
// bare `releaseLock()` leaves the socket open until GC. Cancelling an
|
|
145
|
+
// already-closed/cancelled stream is a harmless no-op that resolves.
|
|
146
|
+
try {
|
|
147
|
+
await reader.cancel();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* stream already errored/closed — nothing to release */
|
|
151
|
+
}
|
|
93
152
|
reader.releaseLock();
|
|
94
153
|
}
|
|
95
154
|
}
|