aivee 0.0.0-stage → 0.1.0-dev.7025438
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/LICENSE +21 -0
- package/README.md +117 -2
- package/docs/architecture.md +401 -0
- package/docs/aspect-ratio-presets.md +72 -0
- package/docs/releasing.md +19 -0
- package/docs/usage.md +1193 -0
- package/package.json +50 -4
- package/requirements-otio.txt +1 -0
- package/src/capabilities.mjs +307 -0
- package/src/cli.mjs +646 -0
- package/src/doctor.mjs +133 -0
- package/src/edits/analysis-to-edl.mjs +43 -0
- package/src/edits/detect-silence.mjs +51 -0
- package/src/edits/find-mistake-candidates.mjs +42 -0
- package/src/edits/plan-shot-placements.mjs +105 -0
- package/src/edits/plan-silence-cuts.mjs +49 -0
- package/src/edits/retime-transcript.mjs +105 -0
- package/src/files.mjs +80 -0
- package/src/index.mjs +44 -0
- package/src/interchange/otio.mjs +204 -0
- package/src/interchange/otio_bridge.py +521 -0
- package/src/media/analysis-provider.mjs +75 -0
- package/src/media/compatibility.mjs +45 -0
- package/src/media/ffmpeg-capabilities.mjs +129 -0
- package/src/media/frame-rate.mjs +58 -0
- package/src/media/frame-time.mjs +22 -0
- package/src/media/preview-assets.mjs +350 -0
- package/src/media/probe.mjs +149 -0
- package/src/media/render-cuts.mjs +90 -0
- package/src/media/scene-detection.mjs +107 -0
- package/src/media/scene-review.mjs +205 -0
- package/src/project/assets.mjs +1194 -0
- package/src/project/create.mjs +100 -0
- package/src/project/presets.mjs +44 -0
- package/src/render/checkpoints.mjs +192 -0
- package/src/render/formats.mjs +163 -0
- package/src/render/hyperframes.mjs +590 -0
- package/src/render/process.mjs +111 -0
- package/src/render/progress.mjs +46 -0
- package/src/render/timeline-control.mjs +53 -0
- package/src/timeline/assemble.mjs +119 -0
- package/src/timeline/audio-effects.mjs +75 -0
- package/src/timeline/audio-mastering.mjs +236 -0
- package/src/timeline/audio-mix.mjs +105 -0
- package/src/timeline/audio-track-effects.mjs +139 -0
- package/src/timeline/audio-volume.mjs +63 -0
- package/src/timeline/background.mjs +8 -0
- package/src/timeline/blend-mode.mjs +34 -0
- package/src/timeline/caption-layouts.mjs +61 -0
- package/src/timeline/captions.mjs +80 -0
- package/src/timeline/edit.mjs +965 -0
- package/src/timeline/fingerprint.mjs +6 -0
- package/src/timeline/from-edl.mjs +31 -0
- package/src/timeline/history-store.mjs +281 -0
- package/src/timeline/history.mjs +69 -0
- package/src/timeline/inspect.mjs +117 -0
- package/src/timeline/keyframes.mjs +158 -0
- package/src/timeline/layers.mjs +18 -0
- package/src/timeline/mask.mjs +53 -0
- package/src/timeline/project.mjs +52 -0
- package/src/timeline/render.mjs +759 -0
- package/src/timeline/sidechain-render.mjs +209 -0
- package/src/timeline/source-fit.mjs +15 -0
- package/src/timeline/speech-bubble.mjs +26 -0
- package/src/timeline/speed-ramp.mjs +188 -0
- package/src/timeline/track-state.mjs +22 -0
- package/src/timeline/transition.mjs +34 -0
- package/src/timeline/validate.mjs +322 -0
- package/src/transcript/corrections.mjs +21 -0
- package/src/transcript/normalize.mjs +17 -0
- package/src/transcript/provider.mjs +42 -0
- package/src/transcript/subtitles.mjs +57 -0
- package/src/transcript/validate.mjs +39 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { assertEdlApproved, validateEdl } from '../edits/retime-transcript.mjs';
|
|
2
|
+
import { validateTimeline } from './validate.mjs';
|
|
3
|
+
|
|
4
|
+
export function timelineFromEdl(edl, assetId, project = {}) {
|
|
5
|
+
validateEdl(edl, edl?.sourceDurationSeconds);
|
|
6
|
+
assertEdlApproved(edl);
|
|
7
|
+
if (!/^[a-z][a-z0-9-]*$/.test(assetId ?? '')) throw new TypeError('assetId must be a kebab-case id.');
|
|
8
|
+
const cuts = edl.cuts;
|
|
9
|
+
const clips = [];
|
|
10
|
+
let source = 0, timeline = 0;
|
|
11
|
+
for (let i = 0; i <= cuts.length; i++) {
|
|
12
|
+
const end = cuts[i]?.sourceStart ?? edl.sourceDurationSeconds;
|
|
13
|
+
if (end > source) {
|
|
14
|
+
const durationSeconds = end - source;
|
|
15
|
+
clips.push({ id: `clip-${clips.length + 1}`, assetId, sourceIn: source, sourceOut: end, timelineStart: timeline, durationSeconds });
|
|
16
|
+
timeline += durationSeconds;
|
|
17
|
+
}
|
|
18
|
+
if (cuts[i]) source = cuts[i].sourceEnd;
|
|
19
|
+
}
|
|
20
|
+
const tracks = [{ id: 'video-main', kind: 'video', order: 0, clips }];
|
|
21
|
+
if (project.includeAudioTrack) {
|
|
22
|
+
tracks.push({
|
|
23
|
+
id: 'audio-main',
|
|
24
|
+
kind: 'audio',
|
|
25
|
+
order: 1,
|
|
26
|
+
clips: clips.map((clip, index) => ({ ...clip, id: `audio-clip-${index + 1}` })),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const result = { schemaVersion: 1, project: { id: project.id ?? 'edited-video', width: project.width ?? 1920, height: project.height ?? 1080, fps: project.fps ?? 30, durationSeconds: timeline, ...(project.sourceFit === undefined ? {} : { sourceFit: project.sourceFit }), ...(project.backgroundColor === undefined ? {} : { backgroundColor: project.backgroundColor }) }, assets: [{ id: assetId, path: project.assetPath ?? 'assets/source.mp4', ...(project.assetType ? { type: project.assetType } : {}), ...(project.assetHasAudio !== undefined ? { hasAudio: project.assetHasAudio } : {}), ...(project.assetVideoStreams !== undefined ? { videoStreams: project.assetVideoStreams } : {}), ...(project.assetAudioStreams !== undefined ? { audioStreams: project.assetAudioStreams } : {}), ...(project.assetDurationSeconds !== undefined ? { durationSeconds: project.assetDurationSeconds } : {}), ...(project.assetStreamTiming !== undefined ? { streamTiming: project.assetStreamTiming } : {}) }], tracks };
|
|
30
|
+
return validateTimeline(result);
|
|
31
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { editTimeline, editTimelineBatch } from './edit.mjs';
|
|
5
|
+
import { validateTimeline } from './validate.mjs';
|
|
6
|
+
import { timelineFingerprint } from './fingerprint.mjs';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_LIMIT = 20;
|
|
9
|
+
const MAX_LIMIT = 100;
|
|
10
|
+
const MAX_HISTORY_BYTES = 64 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
export function editTimelineFile(timelinePath, operation, { limit } = {}) {
|
|
13
|
+
return withHistoryLock(timelinePath, () => {
|
|
14
|
+
const history = loadHistory(timelinePath, limit, { createIfMissing: true });
|
|
15
|
+
const next = Array.isArray(operation)
|
|
16
|
+
? editTimelineBatch(history.timeline, operation)
|
|
17
|
+
: editTimeline(history.timeline, operation);
|
|
18
|
+
const undo = [...history.undo, history.timeline].slice(-history.limit);
|
|
19
|
+
return commitHistory(timelinePath, {
|
|
20
|
+
...history,
|
|
21
|
+
timeline: next,
|
|
22
|
+
timelineHash: hashTimeline(next),
|
|
23
|
+
previousTimelineHash: history.timelineHash,
|
|
24
|
+
undo,
|
|
25
|
+
redo: [],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Apply an explicitly approved review plan if it was created from the current timeline. */
|
|
31
|
+
export function applyTimelineReviewPlan(timelinePath, plan, { limit } = {}) {
|
|
32
|
+
validateReviewPlan(plan);
|
|
33
|
+
return withHistoryLock(timelinePath, () => {
|
|
34
|
+
const { path, timeline } = readTimeline(timelinePath);
|
|
35
|
+
const baseHash = hashTimeline(timeline);
|
|
36
|
+
if (baseHash !== plan.baseTimelineSha256) {
|
|
37
|
+
throw new Error('Review plan is stale because the timeline changed after the plan was created. Generate a new plan.');
|
|
38
|
+
}
|
|
39
|
+
const next = editTimelineBatch(timeline, plan.operations);
|
|
40
|
+
const history = loadHistory(path, limit);
|
|
41
|
+
if (history.timelineHash !== baseHash) {
|
|
42
|
+
throw new Error('Review plan is stale because the timeline history changed after the plan was created. Generate a new plan.');
|
|
43
|
+
}
|
|
44
|
+
const undo = [...history.undo, history.timeline].slice(-history.limit);
|
|
45
|
+
const result = commitHistory(path, {
|
|
46
|
+
...history,
|
|
47
|
+
timeline: next,
|
|
48
|
+
timelineHash: hashTimeline(next),
|
|
49
|
+
previousTimelineHash: history.timelineHash,
|
|
50
|
+
undo,
|
|
51
|
+
redo: [],
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
...result,
|
|
55
|
+
appliedReviewPlan: plan.type,
|
|
56
|
+
appliedOperationCount: plan.operations.length,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function undoTimelineFile(timelinePath) {
|
|
62
|
+
return withHistoryLock(timelinePath, () => {
|
|
63
|
+
const history = loadHistory(timelinePath);
|
|
64
|
+
if (history.undo.length === 0) return describeHistory(history);
|
|
65
|
+
const undo = [...history.undo];
|
|
66
|
+
const timeline = undo.pop();
|
|
67
|
+
return commitHistory(timelinePath, {
|
|
68
|
+
...history,
|
|
69
|
+
timeline,
|
|
70
|
+
timelineHash: hashTimeline(timeline),
|
|
71
|
+
previousTimelineHash: history.timelineHash,
|
|
72
|
+
undo,
|
|
73
|
+
redo: [...history.redo, history.timeline].slice(-history.limit),
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function redoTimelineFile(timelinePath) {
|
|
79
|
+
return withHistoryLock(timelinePath, () => {
|
|
80
|
+
const history = loadHistory(timelinePath);
|
|
81
|
+
if (history.redo.length === 0) return describeHistory(history);
|
|
82
|
+
const redo = [...history.redo];
|
|
83
|
+
const timeline = redo.pop();
|
|
84
|
+
return commitHistory(timelinePath, {
|
|
85
|
+
...history,
|
|
86
|
+
timeline,
|
|
87
|
+
timelineHash: hashTimeline(timeline),
|
|
88
|
+
previousTimelineHash: history.timelineHash,
|
|
89
|
+
undo: [...history.undo, history.timeline].slice(-history.limit),
|
|
90
|
+
redo,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function getTimelineFileHistory(timelinePath) {
|
|
96
|
+
return withHistoryLock(timelinePath, () => describeHistory(loadHistory(timelinePath)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function resetTimelineFileHistory(timelinePath, { limit = DEFAULT_LIMIT } = {}) {
|
|
100
|
+
validateLimit(limit);
|
|
101
|
+
return withHistoryLock(timelinePath, () => {
|
|
102
|
+
const { timeline } = readTimeline(timelinePath);
|
|
103
|
+
return commitHistory(timelinePath, {
|
|
104
|
+
schemaVersion: 1,
|
|
105
|
+
limit,
|
|
106
|
+
timeline,
|
|
107
|
+
timelineHash: hashTimeline(timeline),
|
|
108
|
+
previousTimelineHash: null,
|
|
109
|
+
undo: [],
|
|
110
|
+
redo: [],
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function loadHistory(timelinePath, requestedLimit, { createIfMissing = false } = {}) {
|
|
116
|
+
if (requestedLimit !== undefined) validateLimit(requestedLimit);
|
|
117
|
+
const { path, timeline } = readTimeline(timelinePath);
|
|
118
|
+
const historyPath = getHistoryPath(path);
|
|
119
|
+
if (!existsSync(historyPath)) {
|
|
120
|
+
const state = {
|
|
121
|
+
schemaVersion: 1,
|
|
122
|
+
limit: requestedLimit ?? DEFAULT_LIMIT,
|
|
123
|
+
timeline,
|
|
124
|
+
timelineHash: hashTimeline(timeline),
|
|
125
|
+
previousTimelineHash: null,
|
|
126
|
+
undo: [],
|
|
127
|
+
redo: [],
|
|
128
|
+
};
|
|
129
|
+
if (createIfMissing) writeHistoryFile(historyPath, state);
|
|
130
|
+
return state;
|
|
131
|
+
}
|
|
132
|
+
if (lstatSync(historyPath).isSymbolicLink()) throw new TypeError('Timeline history file cannot be a symbolic link.');
|
|
133
|
+
if (statSync(historyPath).size > MAX_HISTORY_BYTES) throw new RangeError(`Timeline history exceeds the ${MAX_HISTORY_BYTES / 1024 / 1024} MiB limit.`);
|
|
134
|
+
const state = JSON.parse(readFileSync(historyPath, 'utf8'));
|
|
135
|
+
validateHistory(state);
|
|
136
|
+
if (requestedLimit !== undefined && requestedLimit !== state.limit) {
|
|
137
|
+
throw new TypeError(`Timeline history already uses limit ${state.limit}; reset it to change the limit.`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const diskHash = hashTimeline(timeline);
|
|
141
|
+
if (diskHash === state.timelineHash) return state;
|
|
142
|
+
if (state.previousTimelineHash !== null && diskHash === state.previousTimelineHash) {
|
|
143
|
+
writeTimelineFile(path, state.timeline);
|
|
144
|
+
return state;
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`Timeline changed outside its history session: ${path}. Use timeline history reset to start a new session.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readTimeline(timelinePath) {
|
|
150
|
+
const path = resolve(timelinePath);
|
|
151
|
+
if (!existsSync(path)) throw new Error(`Timeline file not found: ${path}`);
|
|
152
|
+
if (lstatSync(path).isSymbolicLink()) throw new TypeError('Timeline file cannot be a symbolic link when using persistent history.');
|
|
153
|
+
const timeline = JSON.parse(readFileSync(path, 'utf8'));
|
|
154
|
+
validateTimeline(timeline);
|
|
155
|
+
return { path, timeline };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function commitHistory(timelinePath, state) {
|
|
159
|
+
const { path } = readTimeline(timelinePath);
|
|
160
|
+
validateHistory(state);
|
|
161
|
+
writeHistoryFile(getHistoryPath(path), state);
|
|
162
|
+
writeTimelineFile(path, state.timeline);
|
|
163
|
+
return describeHistory(state);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function withHistoryLock(timelinePath, operation) {
|
|
167
|
+
const timeline = resolve(timelinePath);
|
|
168
|
+
const lockPath = join(dirname(timeline), `.${basename(timeline)}.history.lock`);
|
|
169
|
+
let descriptor;
|
|
170
|
+
try {
|
|
171
|
+
descriptor = openSync(lockPath, 'wx');
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code === 'EEXIST' && removeStaleHistoryLock(lockPath)) descriptor = openSync(lockPath, 'wx');
|
|
174
|
+
else if (error.code === 'EEXIST') throw new Error(`Timeline history is locked by another operation: ${lockPath}`);
|
|
175
|
+
else throw error;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
writeFileSync(descriptor, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
|
|
179
|
+
return operation();
|
|
180
|
+
} finally {
|
|
181
|
+
closeSync(descriptor);
|
|
182
|
+
unlinkSync(lockPath);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function removeStaleHistoryLock(lockPath) {
|
|
187
|
+
try {
|
|
188
|
+
if (lstatSync(lockPath).isSymbolicLink()) throw new TypeError('Timeline history lock cannot be a symbolic link.');
|
|
189
|
+
const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
190
|
+
if (!Number.isSafeInteger(lock.pid) || lock.pid <= 0) {
|
|
191
|
+
if (Date.now() - statSync(lockPath).mtimeMs < 30_000) return false;
|
|
192
|
+
unlinkSync(lockPath);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
process.kill(lock.pid, 0);
|
|
197
|
+
return false;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error.code !== 'ESRCH') return false;
|
|
200
|
+
}
|
|
201
|
+
unlinkSync(lockPath);
|
|
202
|
+
return true;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error.code === 'ENOENT') return true;
|
|
205
|
+
if (error instanceof SyntaxError && Date.now() - statSync(lockPath).mtimeMs >= 30_000) {
|
|
206
|
+
unlinkSync(lockPath);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validateHistory(state) {
|
|
214
|
+
if (!state || state.schemaVersion !== 1 || !Number.isSafeInteger(state.limit)
|
|
215
|
+
|| state.limit < 1 || state.limit > MAX_LIMIT || !Array.isArray(state.undo)
|
|
216
|
+
|| !Array.isArray(state.redo) || state.undo.length > state.limit
|
|
217
|
+
|| state.redo.length > state.limit || !/^[\da-f]{64}$/.test(state.timelineHash ?? '')
|
|
218
|
+
|| (state.previousTimelineHash !== null && !/^[\da-f]{64}$/.test(state.previousTimelineHash ?? ''))) {
|
|
219
|
+
throw new TypeError('Persistent timeline history has an invalid schema.');
|
|
220
|
+
}
|
|
221
|
+
validateTimeline(state.timeline);
|
|
222
|
+
for (const snapshot of [...state.undo, ...state.redo]) validateTimeline(snapshot);
|
|
223
|
+
if (hashTimeline(state.timeline) !== state.timelineHash) throw new TypeError('Persistent timeline history snapshot does not match its fingerprint.');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function validateLimit(limit) {
|
|
227
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
228
|
+
throw new TypeError(`Timeline history limit must be an integer from 1 to ${MAX_LIMIT}.`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function describeHistory(state) {
|
|
233
|
+
return {
|
|
234
|
+
schemaVersion: 1,
|
|
235
|
+
canUndo: state.undo.length > 0,
|
|
236
|
+
canRedo: state.redo.length > 0,
|
|
237
|
+
undoDepth: state.undo.length,
|
|
238
|
+
redoDepth: state.redo.length,
|
|
239
|
+
limit: state.limit,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function writeHistoryFile(path, state) {
|
|
244
|
+
const content = `${JSON.stringify(state, null, 2)}\n`;
|
|
245
|
+
if (Buffer.byteLength(content) > MAX_HISTORY_BYTES) {
|
|
246
|
+
throw new RangeError(`Timeline history exceeds the ${MAX_HISTORY_BYTES / 1024 / 1024} MiB limit.`);
|
|
247
|
+
}
|
|
248
|
+
writeAtomically(path, content);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function writeTimelineFile(path, timeline) {
|
|
252
|
+
writeAtomically(path, `${JSON.stringify(timeline, null, 2)}\n`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function writeAtomically(path, content) {
|
|
256
|
+
const temporaryPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
257
|
+
try {
|
|
258
|
+
writeFileSync(temporaryPath, content, { flag: 'wx' });
|
|
259
|
+
renameSync(temporaryPath, path);
|
|
260
|
+
} finally {
|
|
261
|
+
rmSync(temporaryPath, { force: true });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getHistoryPath(timelinePath) {
|
|
266
|
+
return join(dirname(timelinePath), `.${basename(timelinePath)}.history.json`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function hashTimeline(timeline) {
|
|
270
|
+
return timelineFingerprint(timeline);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function validateReviewPlan(plan) {
|
|
274
|
+
if (!plan || typeof plan !== 'object' || Array.isArray(plan)
|
|
275
|
+
|| plan.schemaVersion !== 1 || plan.reviewRequired !== true
|
|
276
|
+
|| !/^[a-z][a-z0-9-]*$/.test(plan.type ?? '')
|
|
277
|
+
|| !/^[a-f0-9]{64}$/.test(plan.baseTimelineSha256 ?? '')
|
|
278
|
+
|| !Array.isArray(plan.operations) || plan.operations.length === 0 || plan.operations.length > 2000) {
|
|
279
|
+
throw new TypeError('Review plan must be a schemaVersion 1 review-required plan with a SHA-256 timeline base and 1–2000 operations.');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { editTimeline, editTimelineBatch } from './edit.mjs';
|
|
2
|
+
import { validateTimeline } from './validate.mjs';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_HISTORY_LIMIT = 100;
|
|
5
|
+
|
|
6
|
+
/** Create an isolated, bounded undo/redo history for immutable timeline edits. */
|
|
7
|
+
export function createTimelineHistory(timeline, { limit = DEFAULT_HISTORY_LIMIT } = {}) {
|
|
8
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
|
|
9
|
+
throw new TypeError('Timeline history limit must be an integer from 1 to 10000.');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
validateTimeline(timeline);
|
|
13
|
+
let current = structuredClone(timeline);
|
|
14
|
+
const undoStack = [];
|
|
15
|
+
const redoStack = [];
|
|
16
|
+
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
get current() {
|
|
19
|
+
return structuredClone(current);
|
|
20
|
+
},
|
|
21
|
+
get canUndo() {
|
|
22
|
+
return undoStack.length > 0;
|
|
23
|
+
},
|
|
24
|
+
get canRedo() {
|
|
25
|
+
return redoStack.length > 0;
|
|
26
|
+
},
|
|
27
|
+
get undoDepth() {
|
|
28
|
+
return undoStack.length;
|
|
29
|
+
},
|
|
30
|
+
get redoDepth() {
|
|
31
|
+
return redoStack.length;
|
|
32
|
+
},
|
|
33
|
+
apply(operation) {
|
|
34
|
+
const next = editTimeline(current, operation);
|
|
35
|
+
undoStack.push(current);
|
|
36
|
+
if (undoStack.length > limit) undoStack.shift();
|
|
37
|
+
current = next;
|
|
38
|
+
redoStack.length = 0;
|
|
39
|
+
return structuredClone(current);
|
|
40
|
+
},
|
|
41
|
+
applyBatch(operations) {
|
|
42
|
+
const next = editTimelineBatch(current, operations);
|
|
43
|
+
undoStack.push(current);
|
|
44
|
+
if (undoStack.length > limit) undoStack.shift();
|
|
45
|
+
current = next;
|
|
46
|
+
redoStack.length = 0;
|
|
47
|
+
return structuredClone(current);
|
|
48
|
+
},
|
|
49
|
+
undo() {
|
|
50
|
+
if (undoStack.length === 0) return structuredClone(current);
|
|
51
|
+
redoStack.push(current);
|
|
52
|
+
current = undoStack.pop();
|
|
53
|
+
return structuredClone(current);
|
|
54
|
+
},
|
|
55
|
+
redo() {
|
|
56
|
+
if (redoStack.length === 0) return structuredClone(current);
|
|
57
|
+
undoStack.push(current);
|
|
58
|
+
current = redoStack.pop();
|
|
59
|
+
return structuredClone(current);
|
|
60
|
+
},
|
|
61
|
+
reset(nextTimeline) {
|
|
62
|
+
validateTimeline(nextTimeline);
|
|
63
|
+
current = structuredClone(nextTimeline);
|
|
64
|
+
undoStack.length = 0;
|
|
65
|
+
redoStack.length = 0;
|
|
66
|
+
return structuredClone(current);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { validateTimeline } from './validate.mjs';
|
|
2
|
+
|
|
3
|
+
/** Return a compact, deterministic editing overview for agents and review tools. */
|
|
4
|
+
export function inspectTimeline(timeline) {
|
|
5
|
+
validateTimeline(timeline);
|
|
6
|
+
const assets = new Map(timeline.assets.map((asset) => [asset.id, asset]));
|
|
7
|
+
const tracks = [...timeline.tracks]
|
|
8
|
+
.sort((left, right) => left.order - right.order)
|
|
9
|
+
.map((track) => {
|
|
10
|
+
const clips = [...track.clips]
|
|
11
|
+
.sort((left, right) => left.timelineStart - right.timelineStart
|
|
12
|
+
|| (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))
|
|
13
|
+
.map((clip) => inspectClip(track, clip, assets.get(clip.assetId)));
|
|
14
|
+
return {
|
|
15
|
+
id: track.id,
|
|
16
|
+
kind: track.kind,
|
|
17
|
+
order: track.order,
|
|
18
|
+
...(track.name === undefined ? {} : { name: track.name }),
|
|
19
|
+
...(track.visible === undefined ? {} : { visible: track.visible }),
|
|
20
|
+
...(track.muted === undefined ? {} : { muted: track.muted }),
|
|
21
|
+
...(track.solo === undefined ? {} : { solo: track.solo }),
|
|
22
|
+
...(track.locked === undefined ? {} : { locked: track.locked }),
|
|
23
|
+
...(track.volume === undefined ? {} : { volume: track.volume }),
|
|
24
|
+
...(track.volumeKeyframes === undefined ? {} : { volumeKeyframes: structuredClone(track.volumeKeyframes) }),
|
|
25
|
+
...(track.audioEffects === undefined ? {} : { audioEffects: structuredClone(track.audioEffects) }),
|
|
26
|
+
...(track.allowOverlap === undefined ? {} : { allowOverlap: track.allowOverlap }),
|
|
27
|
+
clipCount: clips.length,
|
|
28
|
+
clips,
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const clipCounts = Object.fromEntries(['video', 'audio', 'overlay'].map((kind) => [
|
|
33
|
+
kind,
|
|
34
|
+
tracks.filter((track) => track.kind === kind).reduce((total, track) => total + track.clipCount, 0),
|
|
35
|
+
]));
|
|
36
|
+
return {
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
project: {
|
|
39
|
+
id: timeline.project.id,
|
|
40
|
+
width: timeline.project.width,
|
|
41
|
+
height: timeline.project.height,
|
|
42
|
+
fps: timeline.project.fps,
|
|
43
|
+
durationSeconds: timeline.project.durationSeconds,
|
|
44
|
+
...(timeline.project.sourceFit === undefined ? {} : { sourceFit: timeline.project.sourceFit }),
|
|
45
|
+
...(timeline.project.backgroundColor === undefined ? {} : { backgroundColor: timeline.project.backgroundColor }),
|
|
46
|
+
},
|
|
47
|
+
...(timeline.audioMix === undefined ? {} : { audioMix: structuredClone(timeline.audioMix) }),
|
|
48
|
+
...(timeline.audioMastering === undefined ? {} : { audioMastering: structuredClone(timeline.audioMastering) }),
|
|
49
|
+
...(timeline.lineage === undefined ? {} : { lineage: structuredClone(timeline.lineage) }),
|
|
50
|
+
markers: [...(timeline.markers ?? [])]
|
|
51
|
+
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))
|
|
52
|
+
.map((marker) => structuredClone(marker)),
|
|
53
|
+
summary: {
|
|
54
|
+
assetCount: timeline.assets.length,
|
|
55
|
+
trackCount: tracks.length,
|
|
56
|
+
clipCount: Object.values(clipCounts).reduce((total, count) => total + count, 0),
|
|
57
|
+
clipCounts,
|
|
58
|
+
markerCount: timeline.markers?.length ?? 0,
|
|
59
|
+
},
|
|
60
|
+
assets: timeline.assets.map((asset) => ({
|
|
61
|
+
id: asset.id,
|
|
62
|
+
path: asset.path,
|
|
63
|
+
...(asset.type === undefined ? {} : { type: asset.type }),
|
|
64
|
+
...(asset.durationSeconds === undefined ? {} : { durationSeconds: asset.durationSeconds }),
|
|
65
|
+
...(asset.width === undefined ? {} : { width: asset.width }),
|
|
66
|
+
...(asset.height === undefined ? {} : { height: asset.height }),
|
|
67
|
+
...(asset.frameRate === undefined ? {} : { frameRate: asset.frameRate }),
|
|
68
|
+
...(asset.hasAudio === undefined ? {} : { hasAudio: asset.hasAudio }),
|
|
69
|
+
...(asset.videoStreams === undefined ? {} : { videoStreams: structuredClone(asset.videoStreams) }),
|
|
70
|
+
...(asset.audioStreams === undefined ? {} : { audioStreams: structuredClone(asset.audioStreams) }),
|
|
71
|
+
...(asset.streamTiming === undefined ? {} : { streamTiming: structuredClone(asset.streamTiming) }),
|
|
72
|
+
...(asset.proxies === undefined ? {} : { proxies: structuredClone(asset.proxies) }),
|
|
73
|
+
})),
|
|
74
|
+
tracks,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inspectClip(track, clip, asset) {
|
|
79
|
+
const result = {
|
|
80
|
+
id: clip.id,
|
|
81
|
+
assetId: clip.assetId ?? null,
|
|
82
|
+
...(clip.enabled === undefined ? {} : { enabled: clip.enabled }),
|
|
83
|
+
mediaType: track.kind === 'overlay' ? (clip.text === undefined ? 'composition' : 'text') : asset?.type ?? track.kind,
|
|
84
|
+
timeline: {
|
|
85
|
+
startSeconds: clip.timelineStart,
|
|
86
|
+
endSeconds: roundInspectionSeconds(clip.timelineStart + clip.durationSeconds),
|
|
87
|
+
durationSeconds: clip.durationSeconds,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
if (track.kind !== 'overlay') {
|
|
91
|
+
result.source = {
|
|
92
|
+
inSeconds: clip.sourceIn,
|
|
93
|
+
outSeconds: clip.sourceOut,
|
|
94
|
+
...(clip.playbackRate === undefined ? {} : { playbackRate: clip.playbackRate }),
|
|
95
|
+
...(clip.speedRamp === undefined ? {} : { speedRamp: clip.speedRamp }),
|
|
96
|
+
...(clip.reverse === undefined ? {} : { reverse: clip.reverse }),
|
|
97
|
+
...(clip.videoStreamIndex === undefined ? {} : { videoStreamIndex: clip.videoStreamIndex }),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
for (const field of [
|
|
101
|
+
'text', 'x', 'y', 'width', 'height', 'fontFamily', 'fontSize', 'fontWeight',
|
|
102
|
+
'lineHeight', 'verticalAlign', 'paddingPixels', 'color', 'backgroundColor',
|
|
103
|
+
'textAlign', 'textOutline', 'textShadow', 'wordTimings', 'wordHighlightColor',
|
|
104
|
+
'wordHighlightAnimation', 'volume', 'fadeInSeconds', 'fadeOutSeconds',
|
|
105
|
+
'bubble',
|
|
106
|
+
'audioEffects', 'volumeKeyframes',
|
|
107
|
+
'transitionIn', 'transitionOut', 'blendMode', 'keyframes', 'effects', 'mask',
|
|
108
|
+
'sourceFit',
|
|
109
|
+
]) {
|
|
110
|
+
if (clip[field] !== undefined) result[field] = structuredClone(clip[field]);
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function roundInspectionSeconds(seconds) {
|
|
116
|
+
return Number(seconds.toFixed(6));
|
|
117
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { gsap } from 'gsap';
|
|
2
|
+
|
|
3
|
+
const PROPERTIES = new Set(['x', 'y', 'width', 'height', 'opacity', 'rotationDegrees', 'objectPositionX', 'objectPositionY', 'zoom', 'maskProgress', 'brightness', 'contrast', 'saturation', 'blurPixels', 'hueDegrees']);
|
|
4
|
+
const EASES = new Set([
|
|
5
|
+
'none', 'power1.in', 'power1.out', 'power1.inOut', 'power2.in', 'power2.out', 'power2.inOut',
|
|
6
|
+
'power3.in', 'power3.out', 'power3.inOut', 'power4.in', 'power4.out', 'power4.inOut',
|
|
7
|
+
'sine.in', 'sine.out', 'sine.inOut', 'back.in', 'back.out', 'back.inOut',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function validateVisualKeyframes(track, clip) {
|
|
11
|
+
if (clip.keyframes === undefined) return;
|
|
12
|
+
if (track.kind !== 'video' && track.kind !== 'overlay') {
|
|
13
|
+
throw new TypeError(`Clip ${clip.id} keyframes are supported on video and overlay clips.`);
|
|
14
|
+
}
|
|
15
|
+
if (!Array.isArray(clip.keyframes) || clip.keyframes.length === 0) throw new TypeError(`Clip ${clip.id} keyframes must be a non-empty array.`);
|
|
16
|
+
if (clip.keyframes[0]?.time !== 0) throw new TypeError(`Clip ${clip.id} keyframes must start at time 0.`);
|
|
17
|
+
if (clip.keyframes.some((frame) => frame?.properties?.opacity !== undefined) && ((clip.fadeInSeconds ?? 0) > 0 || (clip.fadeOutSeconds ?? 0) > 0)) {
|
|
18
|
+
throw new TypeError(`Clip ${clip.id} cannot combine opacity keyframes with fadeInSeconds or fadeOutSeconds.`);
|
|
19
|
+
}
|
|
20
|
+
const hasMaskProgress = clip.keyframes.some((frame) => frame?.properties?.maskProgress !== undefined);
|
|
21
|
+
if (hasMaskProgress && clip.mask === undefined) throw new TypeError(`Clip ${clip.id} maskProgress keyframes require a static mask.`);
|
|
22
|
+
if (hasMaskProgress && clip.keyframes[0]?.properties?.maskProgress === undefined) throw new TypeError(`Clip ${clip.id} maskProgress keyframes must set a value at time 0.`);
|
|
23
|
+
|
|
24
|
+
const defaults = track.kind === 'video' || typeof clip.text !== 'string'
|
|
25
|
+
? { x: clip.x ?? 0, y: clip.y ?? 0, width: clip.width ?? 1, height: clip.height ?? 1 }
|
|
26
|
+
: { x: clip.x ?? 0.08, y: clip.y ?? 0.76, width: clip.width ?? 0.84, height: clip.height ?? 0.16 };
|
|
27
|
+
let previousTime = -1;
|
|
28
|
+
const current = { ...defaults };
|
|
29
|
+
for (const [index, frame] of clip.keyframes.entries()) {
|
|
30
|
+
if (!frame || typeof frame !== 'object' || Array.isArray(frame) || !Number.isFinite(frame.time) || frame.time < 0 || frame.time > clip.durationSeconds) {
|
|
31
|
+
throw new TypeError(`Clip ${clip.id} keyframe ${index} time must be within the clip duration.`);
|
|
32
|
+
}
|
|
33
|
+
if (frame.time <= previousTime) throw new TypeError(`Clip ${clip.id} keyframe times must be strictly increasing.`);
|
|
34
|
+
if (!frame.properties || typeof frame.properties !== 'object' || Array.isArray(frame.properties) || Object.keys(frame.properties).length === 0) {
|
|
35
|
+
throw new TypeError(`Clip ${clip.id} keyframe ${index} needs at least one property.`);
|
|
36
|
+
}
|
|
37
|
+
for (const [property, value] of Object.entries(frame.properties)) {
|
|
38
|
+
if (!PROPERTIES.has(property) || !Number.isFinite(value)) throw new TypeError(`Clip ${clip.id} keyframe ${index} has an unsupported or non-finite property: ${property}.`);
|
|
39
|
+
if (property === 'opacity' && (value < 0 || value > 1)) throw new TypeError(`Clip ${clip.id} keyframe opacity must be between 0 and 1.`);
|
|
40
|
+
if (property === 'rotationDegrees' && Math.abs(value) > 3600) throw new TypeError(`Clip ${clip.id} keyframe rotationDegrees must be between -3600 and 3600.`);
|
|
41
|
+
if (['objectPositionX', 'objectPositionY'].includes(property) && (track.kind !== 'video' || value < 0 || value > 1)) {
|
|
42
|
+
throw new TypeError(`Clip ${clip.id} keyframe ${property} must be between 0 and 1 on a video track.`);
|
|
43
|
+
}
|
|
44
|
+
if (property === 'zoom' && (track.kind !== 'video' || value < 1 || value > 4)) {
|
|
45
|
+
throw new TypeError(`Clip ${clip.id} keyframe zoom must be between 1 and 4 on a video track.`);
|
|
46
|
+
}
|
|
47
|
+
if (property === 'maskProgress' && (clip.mask === undefined || value < 0 || value > 1)) {
|
|
48
|
+
throw new TypeError(`Clip ${clip.id} keyframe maskProgress must be between 0 and 1 and requires a static mask.`);
|
|
49
|
+
}
|
|
50
|
+
if (['brightness', 'contrast', 'saturation'].includes(property) && (value < 0 || value > 5)) throw new TypeError(`Clip ${clip.id} keyframe ${property} must be between 0 and 5.`);
|
|
51
|
+
if (property === 'blurPixels' && (value < 0 || value > 100)) throw new TypeError(`Clip ${clip.id} keyframe blurPixels must be between 0 and 100.`);
|
|
52
|
+
if (property === 'hueDegrees' && Math.abs(value) > 360) throw new TypeError(`Clip ${clip.id} keyframe hueDegrees must be between -360 and 360.`);
|
|
53
|
+
if (['width', 'height'].includes(property) && value <= 0) throw new TypeError(`Clip ${clip.id} keyframe ${property} must be positive.`);
|
|
54
|
+
current[property] = value;
|
|
55
|
+
}
|
|
56
|
+
if (![current.x, current.y, current.width, current.height].every(Number.isFinite) || current.x < 0 || current.y < 0 || current.width <= 0 || current.height <= 0 || current.x + current.width > 1 || current.y + current.height > 1) {
|
|
57
|
+
throw new TypeError(`Clip ${clip.id} keyframe placement must fit within normalized canvas bounds.`);
|
|
58
|
+
}
|
|
59
|
+
if (frame.ease !== undefined) validateEase(frame.ease, `Clip ${clip.id} keyframe ease`);
|
|
60
|
+
if (frame.eases !== undefined) {
|
|
61
|
+
if (!frame.eases || typeof frame.eases !== 'object' || Array.isArray(frame.eases)) throw new TypeError(`Clip ${clip.id} keyframe eases must be an object.`);
|
|
62
|
+
for (const [property, ease] of Object.entries(frame.eases)) {
|
|
63
|
+
if (!Object.hasOwn(frame.properties, property)) throw new TypeError(`Clip ${clip.id} keyframe ease ${property} needs a matching property.`);
|
|
64
|
+
validateEase(ease, `Clip ${clip.id} keyframe ease ${property}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
previousTime = frame.time;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function sliceVisualKeyframes(keyframes, start, end) {
|
|
72
|
+
if (!keyframes) return undefined;
|
|
73
|
+
const properties = new Set(keyframes.flatMap((frame) => Object.keys(frame.properties)));
|
|
74
|
+
const frames = new Map();
|
|
75
|
+
const add = (time, property, value, ease) => {
|
|
76
|
+
const frame = frames.get(time) ?? { time, properties: {}, eases: {} };
|
|
77
|
+
frame.properties[property] = value;
|
|
78
|
+
if (ease !== undefined) frame.eases[property] = ease;
|
|
79
|
+
frames.set(time, frame);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const property of properties) {
|
|
83
|
+
const points = keyframes.filter((frame) => Object.hasOwn(frame.properties, property));
|
|
84
|
+
const initial = sampleProperty(points, property, start);
|
|
85
|
+
if (initial !== undefined) add(0, property, initial);
|
|
86
|
+
|
|
87
|
+
for (let index = 0; index < points.length; index++) {
|
|
88
|
+
const point = points[index];
|
|
89
|
+
if (point.time <= start || point.time > end) continue;
|
|
90
|
+
const prior = points[index - 1];
|
|
91
|
+
const ease = prior && point.time > start
|
|
92
|
+
? sliceEase(point.eases?.[property] ?? point.ease ?? 'none', Math.max(0, (start - prior.time) / (point.time - prior.time)), 1)
|
|
93
|
+
: point.eases?.[property] ?? point.ease;
|
|
94
|
+
add(point.time - start, property, point.properties[property], ease);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ending = sampleProperty(points, property, end);
|
|
98
|
+
if (ending !== undefined && !points.some((point) => point.time === end)) {
|
|
99
|
+
const priorIndex = points.findLastIndex((point) => point.time < end);
|
|
100
|
+
const prior = points[priorIndex];
|
|
101
|
+
const next = points[priorIndex + 1];
|
|
102
|
+
if (prior && next) {
|
|
103
|
+
const ease = next.eases?.[property] ?? next.ease ?? 'none';
|
|
104
|
+
const from = Math.max(start, prior.time);
|
|
105
|
+
const to = end;
|
|
106
|
+
add(end - start, property, ending, sliceEase(ease, (from - prior.time) / (next.time - prior.time), (to - prior.time) / (next.time - prior.time)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [...frames.values()]
|
|
112
|
+
.sort((left, right) => left.time - right.time)
|
|
113
|
+
.map((frame) => {
|
|
114
|
+
if (Object.keys(frame.eases).length === 0) delete frame.eases;
|
|
115
|
+
return frame;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sampleProperty(points, property, time) {
|
|
120
|
+
const priorIndex = points.findLastIndex((point) => point.time <= time);
|
|
121
|
+
if (priorIndex < 0) return undefined;
|
|
122
|
+
const prior = points[priorIndex];
|
|
123
|
+
const next = points[priorIndex + 1];
|
|
124
|
+
if (!next) return prior.properties[property];
|
|
125
|
+
const progress = (time - prior.time) / (next.time - prior.time);
|
|
126
|
+
const ease = next.eases?.[property] ?? next.ease ?? 'none';
|
|
127
|
+
return prior.properties[property] + (next.properties[property] - prior.properties[property]) * easeProgress(ease, progress);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sliceEase(ease, from, to) {
|
|
131
|
+
if (typeof ease === 'string') {
|
|
132
|
+
if (from === 0 && to === 1) return ease;
|
|
133
|
+
return { type: 'range', name: ease, from, to };
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
type: 'range', name: ease.name,
|
|
137
|
+
from: ease.from + (ease.to - ease.from) * from,
|
|
138
|
+
to: ease.from + (ease.to - ease.from) * to,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function easeProgress(ease, progress) {
|
|
143
|
+
if (typeof ease === 'string') return gsap.parseEase(ease)(progress);
|
|
144
|
+
const fn = gsap.parseEase(ease.name);
|
|
145
|
+
const from = fn(ease.from), to = fn(ease.to);
|
|
146
|
+
return (fn(ease.from + (ease.to - ease.from) * progress) - from) / (to - from);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validateEase(ease, label) {
|
|
150
|
+
if (typeof ease === 'string' && EASES.has(ease)) return;
|
|
151
|
+
if (!ease || typeof ease !== 'object' || Array.isArray(ease) || ease.type !== 'range' || !EASES.has(ease.name)
|
|
152
|
+
|| Object.keys(ease).some((key) => !['type', 'name', 'from', 'to'].includes(key))
|
|
153
|
+
|| !Number.isFinite(ease.from) || !Number.isFinite(ease.to) || ease.from < 0 || ease.to > 1 || ease.from >= ease.to) {
|
|
154
|
+
throw new TypeError(`${label} is unsupported.`);
|
|
155
|
+
}
|
|
156
|
+
const fn = gsap.parseEase(ease.name);
|
|
157
|
+
if (Math.abs(fn(ease.to) - fn(ease.from)) < 1e-9) throw new TypeError(`${label} has no progress across its range.`);
|
|
158
|
+
}
|