@waveform-playlist/engine 11.0.1 → 11.2.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/index.d.mts +25 -1
- package/dist/index.d.ts +25 -1
- package/dist/index.js +115 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +115 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -219,6 +219,10 @@ interface EngineState {
|
|
|
219
219
|
loopEnd: number;
|
|
220
220
|
/** Whether loop playback is active. */
|
|
221
221
|
isLoopEnabled: boolean;
|
|
222
|
+
/** Whether undo is available. */
|
|
223
|
+
canUndo: boolean;
|
|
224
|
+
/** Whether redo is available. */
|
|
225
|
+
canRedo: boolean;
|
|
222
226
|
}
|
|
223
227
|
/**
|
|
224
228
|
* Configuration options for PlaylistEngine constructor.
|
|
@@ -228,6 +232,8 @@ interface PlaylistEngineOptions {
|
|
|
228
232
|
sampleRate?: number;
|
|
229
233
|
samplesPerPixel?: number;
|
|
230
234
|
zoomLevels?: number[];
|
|
235
|
+
/** Maximum number of undo steps (default 100). */
|
|
236
|
+
undoLimit?: number;
|
|
231
237
|
}
|
|
232
238
|
/**
|
|
233
239
|
* Events emitted by PlaylistEngine.
|
|
@@ -268,7 +274,21 @@ declare class PlaylistEngine {
|
|
|
268
274
|
private _animFrameId;
|
|
269
275
|
private _disposed;
|
|
270
276
|
private _listeners;
|
|
277
|
+
private _undoStack;
|
|
278
|
+
private _redoStack;
|
|
279
|
+
private _inTransaction;
|
|
280
|
+
private _transactionSnapshot;
|
|
281
|
+
private _transactionMutated;
|
|
282
|
+
readonly undoLimit: number;
|
|
271
283
|
constructor(options?: PlaylistEngineOptions);
|
|
284
|
+
get canUndo(): boolean;
|
|
285
|
+
get canRedo(): boolean;
|
|
286
|
+
undo(): void;
|
|
287
|
+
redo(): void;
|
|
288
|
+
clearHistory(): void;
|
|
289
|
+
beginTransaction(): void;
|
|
290
|
+
commitTransaction(): void;
|
|
291
|
+
abortTransaction(): void;
|
|
272
292
|
getState(): EngineState;
|
|
273
293
|
setTracks(tracks: ClipTrack[]): void;
|
|
274
294
|
addTrack(track: ClipTrack): void;
|
|
@@ -288,7 +308,8 @@ declare class PlaylistEngine {
|
|
|
288
308
|
/** Constrain a trim delta using the engine's collision/bounds logic.
|
|
289
309
|
* Uses the clip's current state and neighboring clips for constraints. */
|
|
290
310
|
constrainTrimDelta(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number): number;
|
|
291
|
-
|
|
311
|
+
/** Move a clip by deltaSamples. Returns the constrained delta actually applied (0 if no-op). */
|
|
312
|
+
moveClip(trackId: string, clipId: string, deltaSamples: number, skipAdapter?: boolean): number;
|
|
292
313
|
splitClip(trackId: string, clipId: string, atSample: number): void;
|
|
293
314
|
trimClip(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number, skipAdapter?: boolean): void;
|
|
294
315
|
init(): Promise<void>;
|
|
@@ -311,6 +332,9 @@ declare class PlaylistEngine {
|
|
|
311
332
|
on<K extends EventName>(event: K, listener: EngineEvents[K]): void;
|
|
312
333
|
off<K extends EventName>(event: K, listener: EngineEvents[K]): void;
|
|
313
334
|
dispose(): void;
|
|
335
|
+
private _snapshotTracks;
|
|
336
|
+
private _pushUndoSnapshot;
|
|
337
|
+
private _restoreTracks;
|
|
314
338
|
private _emit;
|
|
315
339
|
/**
|
|
316
340
|
* Returns whether the current playback position is before loopEnd.
|
package/dist/index.d.ts
CHANGED
|
@@ -219,6 +219,10 @@ interface EngineState {
|
|
|
219
219
|
loopEnd: number;
|
|
220
220
|
/** Whether loop playback is active. */
|
|
221
221
|
isLoopEnabled: boolean;
|
|
222
|
+
/** Whether undo is available. */
|
|
223
|
+
canUndo: boolean;
|
|
224
|
+
/** Whether redo is available. */
|
|
225
|
+
canRedo: boolean;
|
|
222
226
|
}
|
|
223
227
|
/**
|
|
224
228
|
* Configuration options for PlaylistEngine constructor.
|
|
@@ -228,6 +232,8 @@ interface PlaylistEngineOptions {
|
|
|
228
232
|
sampleRate?: number;
|
|
229
233
|
samplesPerPixel?: number;
|
|
230
234
|
zoomLevels?: number[];
|
|
235
|
+
/** Maximum number of undo steps (default 100). */
|
|
236
|
+
undoLimit?: number;
|
|
231
237
|
}
|
|
232
238
|
/**
|
|
233
239
|
* Events emitted by PlaylistEngine.
|
|
@@ -268,7 +274,21 @@ declare class PlaylistEngine {
|
|
|
268
274
|
private _animFrameId;
|
|
269
275
|
private _disposed;
|
|
270
276
|
private _listeners;
|
|
277
|
+
private _undoStack;
|
|
278
|
+
private _redoStack;
|
|
279
|
+
private _inTransaction;
|
|
280
|
+
private _transactionSnapshot;
|
|
281
|
+
private _transactionMutated;
|
|
282
|
+
readonly undoLimit: number;
|
|
271
283
|
constructor(options?: PlaylistEngineOptions);
|
|
284
|
+
get canUndo(): boolean;
|
|
285
|
+
get canRedo(): boolean;
|
|
286
|
+
undo(): void;
|
|
287
|
+
redo(): void;
|
|
288
|
+
clearHistory(): void;
|
|
289
|
+
beginTransaction(): void;
|
|
290
|
+
commitTransaction(): void;
|
|
291
|
+
abortTransaction(): void;
|
|
272
292
|
getState(): EngineState;
|
|
273
293
|
setTracks(tracks: ClipTrack[]): void;
|
|
274
294
|
addTrack(track: ClipTrack): void;
|
|
@@ -288,7 +308,8 @@ declare class PlaylistEngine {
|
|
|
288
308
|
/** Constrain a trim delta using the engine's collision/bounds logic.
|
|
289
309
|
* Uses the clip's current state and neighboring clips for constraints. */
|
|
290
310
|
constrainTrimDelta(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number): number;
|
|
291
|
-
|
|
311
|
+
/** Move a clip by deltaSamples. Returns the constrained delta actually applied (0 if no-op). */
|
|
312
|
+
moveClip(trackId: string, clipId: string, deltaSamples: number, skipAdapter?: boolean): number;
|
|
292
313
|
splitClip(trackId: string, clipId: string, atSample: number): void;
|
|
293
314
|
trimClip(trackId: string, clipId: string, boundary: 'left' | 'right', deltaSamples: number, skipAdapter?: boolean): void;
|
|
294
315
|
init(): Promise<void>;
|
|
@@ -311,6 +332,9 @@ declare class PlaylistEngine {
|
|
|
311
332
|
on<K extends EventName>(event: K, listener: EngineEvents[K]): void;
|
|
312
333
|
off<K extends EventName>(event: K, listener: EngineEvents[K]): void;
|
|
313
334
|
dispose(): void;
|
|
335
|
+
private _snapshotTracks;
|
|
336
|
+
private _pushUndoSnapshot;
|
|
337
|
+
private _restoreTracks;
|
|
314
338
|
private _emit;
|
|
315
339
|
/**
|
|
316
340
|
* Returns whether the current playback position is before loopEnd.
|
package/dist/index.js
CHANGED
|
@@ -208,9 +208,15 @@ var PlaylistEngine = class {
|
|
|
208
208
|
this._disposed = false;
|
|
209
209
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
210
210
|
this._listeners = /* @__PURE__ */ new Map();
|
|
211
|
+
this._undoStack = [];
|
|
212
|
+
this._redoStack = [];
|
|
213
|
+
this._inTransaction = false;
|
|
214
|
+
this._transactionSnapshot = null;
|
|
215
|
+
this._transactionMutated = false;
|
|
211
216
|
this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
212
217
|
this._zoomLevels = [...options.zoomLevels ?? DEFAULT_ZOOM_LEVELS];
|
|
213
218
|
this._adapter = options.adapter ?? null;
|
|
219
|
+
this.undoLimit = options.undoLimit ?? 100;
|
|
214
220
|
if (this._zoomLevels.length === 0) {
|
|
215
221
|
throw new Error("PlaylistEngine: zoomLevels must not be empty");
|
|
216
222
|
}
|
|
@@ -224,6 +230,71 @@ var PlaylistEngine = class {
|
|
|
224
230
|
this._zoomIndex = zoomIndex;
|
|
225
231
|
}
|
|
226
232
|
// ---------------------------------------------------------------------------
|
|
233
|
+
// Undo/Redo
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
get canUndo() {
|
|
236
|
+
return this._undoStack.length > 0;
|
|
237
|
+
}
|
|
238
|
+
get canRedo() {
|
|
239
|
+
return this._redoStack.length > 0;
|
|
240
|
+
}
|
|
241
|
+
undo() {
|
|
242
|
+
if (this._undoStack.length === 0) return;
|
|
243
|
+
const snapshot = this._undoStack.pop();
|
|
244
|
+
this._redoStack.push(this._snapshotTracks());
|
|
245
|
+
this._restoreTracks(snapshot);
|
|
246
|
+
}
|
|
247
|
+
redo() {
|
|
248
|
+
if (this._redoStack.length === 0) return;
|
|
249
|
+
const snapshot = this._redoStack.pop();
|
|
250
|
+
this._undoStack.push(this._snapshotTracks());
|
|
251
|
+
this._restoreTracks(snapshot);
|
|
252
|
+
}
|
|
253
|
+
clearHistory() {
|
|
254
|
+
this._undoStack = [];
|
|
255
|
+
this._redoStack = [];
|
|
256
|
+
}
|
|
257
|
+
beginTransaction() {
|
|
258
|
+
if (this._inTransaction) {
|
|
259
|
+
console.warn(
|
|
260
|
+
"[waveform-playlist/engine] beginTransaction: already in a transaction, previous snapshot will be overwritten"
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
this._transactionSnapshot = this._snapshotTracks();
|
|
264
|
+
this._inTransaction = true;
|
|
265
|
+
this._transactionMutated = false;
|
|
266
|
+
}
|
|
267
|
+
commitTransaction() {
|
|
268
|
+
if (!this._inTransaction || this._transactionSnapshot === null) {
|
|
269
|
+
console.warn("[waveform-playlist/engine] commitTransaction: no active transaction to commit");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (this._transactionMutated) {
|
|
273
|
+
this._undoStack.push(this._transactionSnapshot);
|
|
274
|
+
if (this._undoStack.length > this.undoLimit) {
|
|
275
|
+
this._undoStack.shift();
|
|
276
|
+
}
|
|
277
|
+
this._redoStack = [];
|
|
278
|
+
}
|
|
279
|
+
this._transactionSnapshot = null;
|
|
280
|
+
this._inTransaction = false;
|
|
281
|
+
this._transactionMutated = false;
|
|
282
|
+
}
|
|
283
|
+
abortTransaction() {
|
|
284
|
+
if (!this._inTransaction || this._transactionSnapshot === null) {
|
|
285
|
+
console.warn("[waveform-playlist/engine] abortTransaction: no active transaction to abort");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const snapshot = this._transactionSnapshot;
|
|
289
|
+
const mutated = this._transactionMutated;
|
|
290
|
+
this._transactionSnapshot = null;
|
|
291
|
+
this._inTransaction = false;
|
|
292
|
+
this._transactionMutated = false;
|
|
293
|
+
if (mutated) {
|
|
294
|
+
this._restoreTracks(snapshot);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
227
298
|
// State snapshot
|
|
228
299
|
// ---------------------------------------------------------------------------
|
|
229
300
|
getState() {
|
|
@@ -244,19 +315,23 @@ var PlaylistEngine = class {
|
|
|
244
315
|
masterVolume: this._masterVolume,
|
|
245
316
|
loopStart: this._loopStart,
|
|
246
317
|
loopEnd: this._loopEnd,
|
|
247
|
-
isLoopEnabled: this._isLoopEnabled
|
|
318
|
+
isLoopEnabled: this._isLoopEnabled,
|
|
319
|
+
canUndo: this.canUndo,
|
|
320
|
+
canRedo: this.canRedo
|
|
248
321
|
};
|
|
249
322
|
}
|
|
250
323
|
// ---------------------------------------------------------------------------
|
|
251
324
|
// Track Management
|
|
252
325
|
// ---------------------------------------------------------------------------
|
|
253
326
|
setTracks(tracks) {
|
|
327
|
+
this.clearHistory();
|
|
254
328
|
this._tracks = [...tracks];
|
|
255
329
|
this._tracksVersion++;
|
|
256
330
|
this._adapter?.setTracks(this._tracks);
|
|
257
331
|
this._emitStateChange();
|
|
258
332
|
}
|
|
259
333
|
addTrack(track) {
|
|
334
|
+
this._pushUndoSnapshot();
|
|
260
335
|
this._tracks = [...this._tracks, track];
|
|
261
336
|
this._tracksVersion++;
|
|
262
337
|
if (this._adapter?.addTrack) {
|
|
@@ -268,6 +343,7 @@ var PlaylistEngine = class {
|
|
|
268
343
|
}
|
|
269
344
|
removeTrack(trackId) {
|
|
270
345
|
if (!this._tracks.some((t) => t.id === trackId)) return;
|
|
346
|
+
this._pushUndoSnapshot();
|
|
271
347
|
this._tracks = this._tracks.filter((t) => t.id !== trackId);
|
|
272
348
|
this._tracksVersion++;
|
|
273
349
|
if (this._selectedTrackId === trackId) {
|
|
@@ -285,6 +361,7 @@ var PlaylistEngine = class {
|
|
|
285
361
|
const resolved = track ?? this._tracks.find((t) => t.id === trackId);
|
|
286
362
|
if (!resolved) return;
|
|
287
363
|
if (track) {
|
|
364
|
+
this._pushUndoSnapshot();
|
|
288
365
|
this._tracks = this._tracks.map((t) => t.id === trackId ? track : t);
|
|
289
366
|
this._tracksVersion++;
|
|
290
367
|
}
|
|
@@ -349,24 +426,26 @@ var PlaylistEngine = class {
|
|
|
349
426
|
// ---------------------------------------------------------------------------
|
|
350
427
|
// Clip Editing (delegates to operations/)
|
|
351
428
|
// ---------------------------------------------------------------------------
|
|
429
|
+
/** Move a clip by deltaSamples. Returns the constrained delta actually applied (0 if no-op). */
|
|
352
430
|
moveClip(trackId, clipId, deltaSamples, skipAdapter = false) {
|
|
353
431
|
const track = this._tracks.find((t) => t.id === trackId);
|
|
354
432
|
if (!track) {
|
|
355
433
|
console.warn(`[waveform-playlist/engine] moveClip: track "${trackId}" not found`);
|
|
356
|
-
return;
|
|
434
|
+
return 0;
|
|
357
435
|
}
|
|
358
436
|
const clipIndex = track.clips.findIndex((c) => c.id === clipId);
|
|
359
437
|
if (clipIndex === -1) {
|
|
360
438
|
console.warn(
|
|
361
439
|
`[waveform-playlist/engine] moveClip: clip "${clipId}" not found in track "${trackId}"`
|
|
362
440
|
);
|
|
363
|
-
return;
|
|
441
|
+
return 0;
|
|
364
442
|
}
|
|
365
443
|
const clip = track.clips[clipIndex];
|
|
366
444
|
const sortedClips = (0, import_core2.sortClipsByTime)(track.clips);
|
|
367
445
|
const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
|
|
368
446
|
const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);
|
|
369
|
-
if (constrainedDelta === 0) return;
|
|
447
|
+
if (constrainedDelta === 0) return 0;
|
|
448
|
+
this._pushUndoSnapshot();
|
|
370
449
|
this._tracks = this._tracks.map((t) => {
|
|
371
450
|
if (t.id !== trackId) return t;
|
|
372
451
|
const newClips = t.clips.map(
|
|
@@ -382,6 +461,7 @@ var PlaylistEngine = class {
|
|
|
382
461
|
this._updateTrackOnAdapter(trackId);
|
|
383
462
|
}
|
|
384
463
|
this._emitStateChange();
|
|
464
|
+
return constrainedDelta;
|
|
385
465
|
}
|
|
386
466
|
splitClip(trackId, clipId, atSample) {
|
|
387
467
|
const track = this._tracks.find((t) => t.id === trackId);
|
|
@@ -404,6 +484,7 @@ var PlaylistEngine = class {
|
|
|
404
484
|
);
|
|
405
485
|
return;
|
|
406
486
|
}
|
|
487
|
+
this._pushUndoSnapshot();
|
|
407
488
|
const { left, right } = splitClip(clip, atSample);
|
|
408
489
|
this._tracks = this._tracks.map((t) => {
|
|
409
490
|
if (t.id !== trackId) return t;
|
|
@@ -441,6 +522,7 @@ var PlaylistEngine = class {
|
|
|
441
522
|
minDuration
|
|
442
523
|
);
|
|
443
524
|
if (constrained === 0) return;
|
|
525
|
+
this._pushUndoSnapshot();
|
|
444
526
|
this._tracks = this._tracks.map((t) => {
|
|
445
527
|
if (t.id !== trackId) return t;
|
|
446
528
|
const newClips = t.clips.map((c, i) => {
|
|
@@ -638,6 +720,35 @@ var PlaylistEngine = class {
|
|
|
638
720
|
// ---------------------------------------------------------------------------
|
|
639
721
|
// Private helpers
|
|
640
722
|
// ---------------------------------------------------------------------------
|
|
723
|
+
_snapshotTracks() {
|
|
724
|
+
return this._tracks.map((t) => ({ ...t, clips: t.clips.map((c) => ({ ...c })) }));
|
|
725
|
+
}
|
|
726
|
+
_pushUndoSnapshot() {
|
|
727
|
+
if (this._inTransaction) {
|
|
728
|
+
this._transactionMutated = true;
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
this._undoStack.push(this._snapshotTracks());
|
|
732
|
+
if (this._undoStack.length > this.undoLimit) {
|
|
733
|
+
this._undoStack.shift();
|
|
734
|
+
}
|
|
735
|
+
this._redoStack = [];
|
|
736
|
+
}
|
|
737
|
+
_restoreTracks(snapshot) {
|
|
738
|
+
const oldTracks = this._tracks;
|
|
739
|
+
this._tracks = snapshot;
|
|
740
|
+
this._tracksVersion++;
|
|
741
|
+
if (this._adapter && oldTracks.length === snapshot.length) {
|
|
742
|
+
for (let i = 0; i < snapshot.length; i++) {
|
|
743
|
+
if (oldTracks[i] !== snapshot[i]) {
|
|
744
|
+
this._updateTrackOnAdapter(snapshot[i].id);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
} else {
|
|
748
|
+
this._adapter?.setTracks(this._tracks);
|
|
749
|
+
}
|
|
750
|
+
this._emitStateChange();
|
|
751
|
+
}
|
|
641
752
|
_emit(event, ...args) {
|
|
642
753
|
const listeners = this._listeners.get(event);
|
|
643
754
|
if (listeners) {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/operations/clipOperations.ts","../src/operations/viewportOperations.ts","../src/operations/timelineOperations.ts","../src/PlaylistEngine.ts"],"sourcesContent":["// Operations (pure functions)\nexport * from './operations';\n\n// Engine class\nexport { PlaylistEngine } from './PlaylistEngine';\n\n// Engine types\nexport * from './types';\n","/**\n * Clip Operations\n *\n * Pure functions for constraining clip movement, boundary trimming,\n * and splitting clips on a timeline. All positions are in samples (integers).\n */\n\nimport type { AudioClip } from '@waveform-playlist/core';\nimport { createClip } from '@waveform-playlist/core';\n\n/**\n * Constrain clip movement delta to prevent overlaps with adjacent clips\n * and going before sample 0.\n *\n * @param clip - The clip being dragged\n * @param deltaSamples - Requested movement in samples (negative = left, positive = right)\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the dragged clip in sortedClips\n * @returns Constrained delta that prevents overlaps\n */\nexport function constrainClipDrag(\n clip: AudioClip,\n deltaSamples: number,\n sortedClips: AudioClip[],\n clipIndex: number\n): number {\n let delta = deltaSamples;\n\n // Constraint 1: Cannot go before sample 0\n const minDelta = -clip.startSample;\n delta = Math.max(delta, minDelta);\n\n // Constraint 2: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // clip.startSample + delta >= prevClipEnd\n const minDeltaPrev = prevClipEnd - clip.startSample;\n delta = Math.max(delta, minDeltaPrev);\n }\n\n // Constraint 3: Cannot overlap next clip\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // clip.startSample + clip.durationSamples + delta <= nextClip.startSample\n const maxDeltaNext = nextClip.startSample - (clip.startSample + clip.durationSamples);\n delta = Math.min(delta, maxDeltaNext);\n }\n\n return delta;\n}\n\n/**\n * Constrain boundary trim delta for left or right edge of a clip.\n *\n * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand)\n * - startSample += delta, offsetSamples += delta, durationSamples -= delta\n *\n * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink)\n * - durationSamples += delta\n *\n * @param clip - The clip being trimmed\n * @param deltaSamples - Requested trim delta in samples\n * @param boundary - Which edge is being trimmed: 'left' or 'right'\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the trimmed clip in sortedClips\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns Constrained delta\n */\nexport function constrainBoundaryTrim(\n clip: AudioClip,\n deltaSamples: number,\n boundary: 'left' | 'right',\n sortedClips: AudioClip[],\n clipIndex: number,\n minDurationSamples: number\n): number {\n let delta = deltaSamples;\n\n if (boundary === 'left') {\n // Constraint 1: startSample + delta >= 0\n delta = Math.max(delta, -clip.startSample);\n\n // Constraint 2: offsetSamples + delta >= 0\n delta = Math.max(delta, -clip.offsetSamples);\n\n // Constraint 3: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // startSample + delta >= prevClipEnd\n delta = Math.max(delta, prevClipEnd - clip.startSample);\n }\n\n // Constraint 4: durationSamples - delta >= minDurationSamples\n // delta <= durationSamples - minDurationSamples\n delta = Math.min(delta, clip.durationSamples - minDurationSamples);\n } else {\n // RIGHT boundary\n\n // Constraint 1: durationSamples + delta >= minDurationSamples\n // delta >= minDurationSamples - durationSamples\n delta = Math.max(delta, minDurationSamples - clip.durationSamples);\n\n // Constraint 2: offsetSamples + (durationSamples + delta) <= sourceDurationSamples\n // delta <= sourceDurationSamples - offsetSamples - durationSamples\n delta = Math.min(delta, clip.sourceDurationSamples - clip.offsetSamples - clip.durationSamples);\n\n // Constraint 3: startSample + (durationSamples + delta) <= nextClip.startSample\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // delta <= nextClip.startSample - startSample - durationSamples\n delta = Math.min(delta, nextClip.startSample - clip.startSample - clip.durationSamples);\n }\n }\n\n return delta;\n}\n\n/**\n * Snap a split sample position to the nearest pixel boundary.\n *\n * @param splitSample - The sample position to snap\n * @param samplesPerPixel - Current zoom level (samples per pixel)\n * @returns Snapped sample position\n */\nexport function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number {\n return Math.floor(splitSample / samplesPerPixel) * samplesPerPixel;\n}\n\n/**\n * Split a clip into two clips at the given sample position.\n *\n * The left clip retains the original fadeIn; the right clip retains the original fadeOut.\n * Both clips share the same waveformData reference.\n * If the clip has a name, suffixes \" (1)\" and \" (2)\" are appended.\n *\n * @param clip - The clip to split\n * @param splitSample - The timeline sample position where the split occurs\n * @returns Object with `left` and `right` AudioClip\n */\nexport function splitClip(\n clip: AudioClip,\n splitSample: number\n): { left: AudioClip; right: AudioClip } {\n const leftDuration = splitSample - clip.startSample;\n const rightDuration = clip.durationSamples - leftDuration;\n\n const leftName = clip.name ? `${clip.name} (1)` : undefined;\n const rightName = clip.name ? `${clip.name} (2)` : undefined;\n\n const left = createClip({\n startSample: clip.startSample,\n durationSamples: leftDuration,\n offsetSamples: clip.offsetSamples,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: leftName,\n color: clip.color,\n fadeIn: clip.fadeIn,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n const right = createClip({\n startSample: splitSample,\n durationSamples: rightDuration,\n offsetSamples: clip.offsetSamples + leftDuration,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: rightName,\n color: clip.color,\n fadeOut: clip.fadeOut,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n return { left, right };\n}\n\n/**\n * Check whether a clip can be split at the given sample position.\n *\n * The split point must be strictly inside the clip (not at start or end),\n * and both resulting clips must meet the minimum duration requirement.\n *\n * @param clip - The clip to check\n * @param sample - The timeline sample position to test\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns true if the split is valid\n */\nexport function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean {\n const clipEnd = clip.startSample + clip.durationSamples;\n\n // Must be strictly within clip bounds\n if (sample <= clip.startSample || sample >= clipEnd) {\n return false;\n }\n\n // Both resulting clips must meet minimum duration\n const leftDuration = sample - clip.startSample;\n const rightDuration = clipEnd - sample;\n\n return leftDuration >= minDurationSamples && rightDuration >= minDurationSamples;\n}\n","/**\n * Viewport operations for virtual scrolling.\n *\n * Pure math helpers that determine which portion of the timeline\n * is visible and which canvas chunks need to be mounted.\n */\n\n/**\n * Calculate the visible region with an overscan buffer for virtual scrolling.\n *\n * The buffer extends the visible range on both sides so that chunks are\n * mounted slightly before they scroll into view, preventing flicker.\n *\n * @param scrollLeft - Current horizontal scroll position in pixels\n * @param containerWidth - Width of the scroll container in pixels\n * @param bufferRatio - Multiplier for buffer size (default 1.5x container width)\n * @returns Object with visibleStart and visibleEnd in pixels\n */\nexport function calculateViewportBounds(\n scrollLeft: number,\n containerWidth: number,\n bufferRatio: number = 1.5\n): { visibleStart: number; visibleEnd: number } {\n const buffer = containerWidth * bufferRatio;\n return {\n visibleStart: Math.max(0, scrollLeft - buffer),\n visibleEnd: scrollLeft + containerWidth + buffer,\n };\n}\n\n/**\n * Get an array of chunk indices that overlap the visible viewport.\n *\n * Chunks are fixed-width segments of the total timeline width. Only chunks\n * that intersect [visibleStart, visibleEnd) are included. The last chunk\n * may be narrower than chunkWidth if totalWidth is not evenly divisible.\n *\n * @param totalWidth - Total width of the timeline in pixels\n * @param chunkWidth - Width of each chunk in pixels\n * @param visibleStart - Left edge of the visible region in pixels\n * @param visibleEnd - Right edge of the visible region in pixels\n * @returns Array of chunk indices (0-based) that are visible\n */\nexport function getVisibleChunkIndices(\n totalWidth: number,\n chunkWidth: number,\n visibleStart: number,\n visibleEnd: number\n): number[] {\n const totalChunks = Math.ceil(totalWidth / chunkWidth);\n const indices: number[] = [];\n\n for (let i = 0; i < totalChunks; i++) {\n const chunkLeft = i * chunkWidth;\n const thisChunkWidth = Math.min(totalWidth - chunkLeft, chunkWidth);\n const chunkEnd = chunkLeft + thisChunkWidth;\n\n if (chunkEnd <= visibleStart || chunkLeft >= visibleEnd) {\n continue;\n }\n\n indices.push(i);\n }\n\n return indices;\n}\n\n/**\n * Determine whether a scroll change is large enough to warrant\n * recalculating the viewport and re-rendering chunks.\n *\n * Small scroll movements are ignored to avoid excessive recomputation\n * during smooth scrolling.\n *\n * @param oldScrollLeft - Previous scroll position in pixels\n * @param newScrollLeft - Current scroll position in pixels\n * @param threshold - Minimum pixel delta to trigger an update (default 100)\n * @returns true if the scroll delta meets or exceeds the threshold\n */\nexport function shouldUpdateViewport(\n oldScrollLeft: number,\n newScrollLeft: number,\n threshold: number = 100\n): boolean {\n return Math.abs(oldScrollLeft - newScrollLeft) >= threshold;\n}\n","import type { ClipTrack } from '@waveform-playlist/core';\n\n/**\n * Calculate total timeline duration in seconds from all tracks/clips.\n * Iterates all clips, finds the furthest clip end (startSample + durationSamples),\n * converts to seconds using each clip's sampleRate.\n *\n * @param tracks - Array of clip tracks\n * @returns Duration in seconds\n */\nexport function calculateDuration(tracks: ClipTrack[]): number {\n let maxDuration = 0;\n for (const track of tracks) {\n for (const clip of track.clips) {\n const clipEndSample = clip.startSample + clip.durationSamples;\n const clipEnd = clipEndSample / clip.sampleRate;\n maxDuration = Math.max(maxDuration, clipEnd);\n }\n }\n return maxDuration;\n}\n\n/**\n * Find the zoom level index closest to a given samplesPerPixel.\n * Returns exact match if found, otherwise the index whose value is\n * nearest to the target (by absolute difference).\n *\n * @param targetSamplesPerPixel - The samplesPerPixel value to find\n * @param zoomLevels - Array of available zoom levels (samplesPerPixel values)\n * @returns Index into the zoomLevels array\n */\nexport function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number {\n if (zoomLevels.length === 0) return 0;\n\n let bestIndex = 0;\n let bestDiff = Math.abs(zoomLevels[0] - targetSamplesPerPixel);\n\n for (let i = 1; i < zoomLevels.length; i++) {\n const diff = Math.abs(zoomLevels[i] - targetSamplesPerPixel);\n if (diff < bestDiff) {\n bestDiff = diff;\n bestIndex = i;\n }\n }\n\n return bestIndex;\n}\n\n/**\n * Keep viewport centered during zoom changes.\n * Calculates center time from old zoom, computes new pixel position at new zoom,\n * and returns new scrollLeft clamped to >= 0.\n *\n * @param oldSamplesPerPixel - Previous zoom level\n * @param newSamplesPerPixel - New zoom level\n * @param scrollLeft - Current horizontal scroll position\n * @param containerWidth - Viewport width in pixels\n * @param sampleRate - Audio sample rate\n * @param controlWidth - Width of track controls panel (defaults to 0)\n * @returns New scrollLeft value\n */\nexport function calculateZoomScrollPosition(\n oldSamplesPerPixel: number,\n newSamplesPerPixel: number,\n scrollLeft: number,\n containerWidth: number,\n sampleRate: number,\n controlWidth: number = 0\n): number {\n const centerPixel = scrollLeft + containerWidth / 2 - controlWidth;\n const centerTime = (centerPixel * oldSamplesPerPixel) / sampleRate;\n const newCenterPixel = (centerTime * sampleRate) / newSamplesPerPixel;\n const newScrollLeft = newCenterPixel + controlWidth - containerWidth / 2;\n return Math.max(0, newScrollLeft);\n}\n\n/**\n * Clamp a seek position to the valid range [0, duration].\n *\n * @param time - Requested seek time in seconds\n * @param duration - Maximum duration in seconds\n * @returns Clamped time value\n */\nexport function clampSeekPosition(time: number, duration: number): number {\n return Math.max(0, Math.min(time, duration));\n}\n","/**\n * PlaylistEngine — Stateful, framework-agnostic timeline engine.\n *\n * Composes pure operations from ./operations with an event emitter\n * and optional PlayoutAdapter for audio playback delegation.\n */\n\nimport type { AudioClip, ClipTrack } from '@waveform-playlist/core';\nimport { sortClipsByTime } from '@waveform-playlist/core';\nimport {\n constrainClipDrag,\n constrainBoundaryTrim,\n canSplitAt,\n splitClip as splitClipOp,\n} from './operations/clipOperations';\nimport { calculateDuration, findClosestZoomIndex } from './operations/timelineOperations';\nimport type { PlayoutAdapter, EngineState, EngineEvents, PlaylistEngineOptions } from './types';\n\nconst DEFAULT_SAMPLE_RATE = 48000;\nconst DEFAULT_SAMPLES_PER_PIXEL = 1024;\nconst DEFAULT_ZOOM_LEVELS = [256, 512, 1024, 2048, 4096, 8192];\nconst DEFAULT_MIN_DURATION_SECONDS = 0.1;\n\ntype EventName = keyof EngineEvents;\n\nexport class PlaylistEngine {\n private _tracks: ClipTrack[] = [];\n private _currentTime = 0;\n private _playStartPosition = 0;\n private _isPlaying = false;\n private _selectedTrackId: string | null = null;\n private _sampleRate: number;\n private _zoomLevels: number[];\n private _zoomIndex: number;\n private _selectionStart = 0;\n private _selectionEnd = 0;\n private _masterVolume = 1.0;\n private _loopStart = 0;\n private _loopEnd = 0;\n private _isLoopEnabled = false;\n private _tracksVersion = 0;\n private _adapter: PlayoutAdapter | null;\n private _animFrameId: number | null = null;\n private _disposed = false;\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n private _listeners: Map<string, Set<Function>> = new Map();\n\n constructor(options: PlaylistEngineOptions = {}) {\n this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;\n this._zoomLevels = [...(options.zoomLevels ?? DEFAULT_ZOOM_LEVELS)];\n this._adapter = options.adapter ?? null;\n\n if (this._zoomLevels.length === 0) {\n throw new Error('PlaylistEngine: zoomLevels must not be empty');\n }\n\n const initialSpp = options.samplesPerPixel ?? DEFAULT_SAMPLES_PER_PIXEL;\n const zoomIndex = this._zoomLevels.indexOf(initialSpp);\n if (zoomIndex === -1) {\n throw new Error(\n `PlaylistEngine: samplesPerPixel ${initialSpp} is not in zoomLevels [${this._zoomLevels.join(', ')}]. ` +\n `Either pass a samplesPerPixel value that exists in zoomLevels, or include ${initialSpp} in your zoomLevels array.`\n );\n }\n this._zoomIndex = zoomIndex;\n }\n\n // ---------------------------------------------------------------------------\n // State snapshot\n // ---------------------------------------------------------------------------\n\n getState(): EngineState {\n return {\n tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),\n tracksVersion: this._tracksVersion,\n duration: calculateDuration(this._tracks),\n currentTime: this._currentTime,\n isPlaying: this._isPlaying,\n samplesPerPixel: this._zoomLevels[this._zoomIndex],\n sampleRate: this._sampleRate,\n selectedTrackId: this._selectedTrackId,\n zoomIndex: this._zoomIndex,\n canZoomIn: this._zoomIndex > 0,\n canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,\n selectionStart: this._selectionStart,\n selectionEnd: this._selectionEnd,\n masterVolume: this._masterVolume,\n loopStart: this._loopStart,\n loopEnd: this._loopEnd,\n isLoopEnabled: this._isLoopEnabled,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Track Management\n // ---------------------------------------------------------------------------\n\n setTracks(tracks: ClipTrack[]): void {\n this._tracks = [...tracks];\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n addTrack(track: ClipTrack): void {\n this._tracks = [...this._tracks, track];\n this._tracksVersion++;\n if (this._adapter?.addTrack) {\n this._adapter.addTrack(track);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n removeTrack(trackId: string): void {\n if (!this._tracks.some((t) => t.id === trackId)) return;\n this._tracks = this._tracks.filter((t) => t.id !== trackId);\n this._tracksVersion++;\n if (this._selectedTrackId === trackId) {\n this._selectedTrackId = null;\n }\n if (this._adapter?.removeTrack) {\n this._adapter.removeTrack(trackId);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n /** Update a single track's clips on the adapter (no full rebuild). */\n updateTrack(trackId: string, track?: ClipTrack): void {\n const resolved = track ?? this._tracks.find((t) => t.id === trackId);\n if (!resolved) return;\n if (track) {\n this._tracks = this._tracks.map((t) => (t.id === trackId ? track : t));\n this._tracksVersion++;\n }\n if (this._adapter?.updateTrack) {\n this._adapter.updateTrack(trackId, resolved);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n // Only emit statechange when internal state actually changed\n if (track) this._emitStateChange();\n }\n\n /** Internal: update adapter after modifying this._tracks in place. */\n private _updateTrackOnAdapter(trackId: string): void {\n const t = this._tracks.find((tr) => tr.id === trackId);\n if (!t) return;\n if (this._adapter?.updateTrack) {\n this._adapter.updateTrack(trackId, t);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n }\n\n selectTrack(trackId: string | null): void {\n if (trackId === this._selectedTrackId) return;\n this._selectedTrackId = trackId;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Clip Queries\n // ---------------------------------------------------------------------------\n\n /** Get a clip's full bounds for trim constraint computation. Returns null if not found. */\n getClipBounds(\n trackId: string,\n clipId: string\n ): {\n offsetSamples: number;\n durationSamples: number;\n startSample: number;\n sourceDurationSamples: number;\n } | null {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return null;\n const clip = track.clips.find((c: AudioClip) => c.id === clipId);\n if (!clip) return null;\n return {\n offsetSamples: clip.offsetSamples,\n durationSamples: clip.durationSamples,\n startSample: clip.startSample,\n sourceDurationSamples: clip.sourceDurationSamples,\n };\n }\n\n /** Constrain a trim delta using the engine's collision/bounds logic.\n * Uses the clip's current state and neighboring clips for constraints. */\n constrainTrimDelta(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number\n ): number {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return 0;\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return 0;\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n return constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n }\n\n // ---------------------------------------------------------------------------\n // Clip Editing (delegates to operations/)\n // ---------------------------------------------------------------------------\n\n moveClip(trackId: string, clipId: string, deltaSamples: number, skipAdapter = false): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] moveClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] moveClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n\n const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);\n\n if (constrainedDelta === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) =>\n i === clipIndex\n ? {\n ...c,\n startSample: Math.floor(c.startSample + constrainedDelta),\n }\n : c\n );\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n if (!skipAdapter) {\n this._updateTrackOnAdapter(trackId);\n }\n this._emitStateChange();\n }\n\n splitClip(trackId: string, clipId: string, atSample: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] splitClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] splitClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n if (!canSplitAt(clip, atSample, minDuration)) {\n console.warn(\n `[waveform-playlist/engine] splitClip: cannot split clip \"${clipId}\" at sample ${atSample} ` +\n `(clip range: ${clip.startSample}–${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`\n );\n return;\n }\n\n const { left, right } = splitClipOp(clip, atSample);\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = [...t.clips];\n newClips.splice(clipIndex, 1, left, right);\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._updateTrackOnAdapter(trackId);\n this._emitStateChange();\n }\n\n trimClip(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number,\n skipAdapter = false\n ): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] trimClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] trimClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n const constrained = constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n\n if (constrained === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) => {\n if (i !== clipIndex) return c;\n if (boundary === 'left') {\n return {\n ...c,\n startSample: c.startSample + constrained,\n offsetSamples: c.offsetSamples + constrained,\n durationSamples: c.durationSamples - constrained,\n };\n } else {\n return { ...c, durationSamples: c.durationSamples + constrained };\n }\n });\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n if (!skipAdapter) {\n this._updateTrackOnAdapter(trackId);\n }\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Playback (delegates to adapter, no-ops without adapter)\n // ---------------------------------------------------------------------------\n\n async init(): Promise<void> {\n if (this._adapter) {\n await this._adapter.init();\n }\n }\n\n play(startTime?: number, endTime?: number): void {\n const prevCurrentTime = this._currentTime;\n const prevPlayStartPosition = this._playStartPosition;\n\n if (startTime !== undefined) {\n this._currentTime = Math.max(0, startTime);\n }\n\n // Remember where playback started (Audacity-style: stop returns here)\n this._playStartPosition = this._currentTime;\n\n if (this._adapter) {\n // Configure loop state BEFORE play(). The adapter caches loopStart/\n // loopEnd/enabled from setLoop(), then TonePlayout.play() applies\n // them to the Transport before transport.start() and advances\n // Clock._lastUpdate to skip stale ghost ticks.\n if (endTime !== undefined) {\n // Disable Transport loop for duration-limited playback (selection/annotation)\n this._adapter.setLoop(false, this._loopStart, this._loopEnd);\n } else if (this._isLoopEnabled) {\n // Activate Transport loop if starting before loopEnd. Starting at or\n // past loopEnd plays to the end without looping (click-past-loop behavior).\n const beforeLoopEnd = this._currentTime < this._loopEnd;\n this._adapter.setLoop(beforeLoopEnd, this._loopStart, this._loopEnd);\n }\n try {\n this._adapter.play(this._currentTime, endTime);\n } catch (err) {\n // Restore state so the engine isn't left with a moved playhead\n // but no audio. The throw propagates to the caller.\n this._currentTime = prevCurrentTime;\n this._playStartPosition = prevPlayStartPosition;\n throw err;\n }\n }\n\n this._isPlaying = true;\n this._startTimeUpdateLoop();\n this._emit('play');\n this._emitStateChange();\n }\n\n pause(): void {\n this._isPlaying = false;\n this._stopTimeUpdateLoop();\n this._adapter?.pause();\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n }\n this._emit('pause');\n this._emitStateChange();\n }\n\n stop(): void {\n this._isPlaying = false;\n this._currentTime = this._playStartPosition;\n this._stopTimeUpdateLoop();\n this._adapter?.setLoop(false, this._loopStart, this._loopEnd);\n this._adapter?.stop();\n this._emit('stop');\n this._emitStateChange();\n }\n\n seek(time: number): void {\n this._currentTime = Math.max(0, time);\n this._adapter?.seek(this._currentTime);\n this._emitStateChange();\n }\n\n setMasterVolume(volume: number): void {\n if (volume === this._masterVolume) return;\n this._masterVolume = volume;\n this._adapter?.setMasterVolume(volume);\n this._emitStateChange();\n }\n\n getCurrentTime(): number {\n if (this._isPlaying && this._adapter) {\n return this._adapter.getCurrentTime();\n }\n return this._currentTime;\n }\n\n // ---------------------------------------------------------------------------\n // Selection & Loop\n // ---------------------------------------------------------------------------\n\n setSelection(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._selectionStart && e === this._selectionEnd) return;\n this._selectionStart = s;\n this._selectionEnd = e;\n this._emitStateChange();\n }\n\n setLoopRegion(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._loopStart && e === this._loopEnd) return;\n this._loopStart = s;\n this._loopEnd = e;\n this._adapter?.setLoop(\n this._isLoopEnabled && this._isBeforeLoopEnd(),\n this._loopStart,\n this._loopEnd\n );\n this._emitStateChange();\n }\n\n setLoopEnabled(enabled: boolean): void {\n if (enabled === this._isLoopEnabled) return;\n this._isLoopEnabled = enabled;\n this._adapter?.setLoop(enabled && this._isBeforeLoopEnd(), this._loopStart, this._loopEnd);\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.volume = volume;\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.muted = muted;\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.soloed = soloed;\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.pan = pan;\n this._adapter?.setTrackPan(trackId, pan);\n }\n\n // ---------------------------------------------------------------------------\n // Zoom\n // ---------------------------------------------------------------------------\n\n zoomIn(): void {\n if (this._zoomIndex > 0) {\n this._zoomIndex--;\n this._emitStateChange();\n }\n }\n\n zoomOut(): void {\n if (this._zoomIndex < this._zoomLevels.length - 1) {\n this._zoomIndex++;\n this._emitStateChange();\n }\n }\n\n setZoomLevel(samplesPerPixel: number): void {\n const newIndex = findClosestZoomIndex(samplesPerPixel, this._zoomLevels);\n if (newIndex === this._zoomIndex) return;\n this._zoomIndex = newIndex;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Events\n // ---------------------------------------------------------------------------\n\n on<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n this._listeners.get(event)!.add(listener);\n }\n\n off<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n this._listeners.get(event)?.delete(listener);\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n dispose(): void {\n if (this._disposed) return;\n this._disposed = true;\n this._stopTimeUpdateLoop();\n try {\n this._adapter?.dispose();\n } catch (err) {\n console.warn('[waveform-playlist/engine] Error disposing adapter:', err);\n }\n this._listeners.clear();\n }\n\n // ---------------------------------------------------------------------------\n // Private helpers\n // ---------------------------------------------------------------------------\n\n private _emit(event: string, ...args: unknown[]): void {\n const listeners = this._listeners.get(event);\n if (listeners) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch (error) {\n console.warn('[waveform-playlist/engine] Error in event listener:', error);\n }\n }\n }\n }\n\n /**\n * Returns whether the current playback position is before loopEnd.\n * Used by setLoopEnabled/setLoopRegion during playback — if past loopEnd,\n * Transport loop stays off so playback continues to the end.\n * Note: play() uses an inline check instead — _isPlaying is still false\n * when play() runs, and this method returns true unconditionally when\n * not playing.\n */\n private _isBeforeLoopEnd(): boolean {\n if (!this._isPlaying) return true;\n const t = this._adapter?.getCurrentTime() ?? this._currentTime;\n return t < this._loopEnd;\n }\n\n private _emitStateChange(): void {\n this._emit('statechange', this.getState());\n }\n\n private _startTimeUpdateLoop(): void {\n // Guard for Node.js / SSR environments where RAF is unavailable\n if (typeof requestAnimationFrame === 'undefined') return;\n\n this._stopTimeUpdateLoop();\n\n const tick = () => {\n if (this._disposed || !this._isPlaying) return;\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n this._emit('timeupdate', this._currentTime);\n }\n this._animFrameId = requestAnimationFrame(tick);\n };\n\n this._animFrameId = requestAnimationFrame(tick);\n }\n\n private _stopTimeUpdateLoop(): void {\n if (this._animFrameId !== null && typeof cancelAnimationFrame !== 'undefined') {\n cancelAnimationFrame(this._animFrameId);\n this._animFrameId = null;\n }\n }\n}\n"],"mappings":"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","names":["import_core"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/operations/clipOperations.ts","../src/operations/viewportOperations.ts","../src/operations/timelineOperations.ts","../src/PlaylistEngine.ts"],"sourcesContent":["// Operations (pure functions)\nexport * from './operations';\n\n// Engine class\nexport { PlaylistEngine } from './PlaylistEngine';\n\n// Engine types\nexport * from './types';\n","/**\n * Clip Operations\n *\n * Pure functions for constraining clip movement, boundary trimming,\n * and splitting clips on a timeline. All positions are in samples (integers).\n */\n\nimport type { AudioClip } from '@waveform-playlist/core';\nimport { createClip } from '@waveform-playlist/core';\n\n/**\n * Constrain clip movement delta to prevent overlaps with adjacent clips\n * and going before sample 0.\n *\n * @param clip - The clip being dragged\n * @param deltaSamples - Requested movement in samples (negative = left, positive = right)\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the dragged clip in sortedClips\n * @returns Constrained delta that prevents overlaps\n */\nexport function constrainClipDrag(\n clip: AudioClip,\n deltaSamples: number,\n sortedClips: AudioClip[],\n clipIndex: number\n): number {\n let delta = deltaSamples;\n\n // Constraint 1: Cannot go before sample 0\n const minDelta = -clip.startSample;\n delta = Math.max(delta, minDelta);\n\n // Constraint 2: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // clip.startSample + delta >= prevClipEnd\n const minDeltaPrev = prevClipEnd - clip.startSample;\n delta = Math.max(delta, minDeltaPrev);\n }\n\n // Constraint 3: Cannot overlap next clip\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // clip.startSample + clip.durationSamples + delta <= nextClip.startSample\n const maxDeltaNext = nextClip.startSample - (clip.startSample + clip.durationSamples);\n delta = Math.min(delta, maxDeltaNext);\n }\n\n return delta;\n}\n\n/**\n * Constrain boundary trim delta for left or right edge of a clip.\n *\n * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand)\n * - startSample += delta, offsetSamples += delta, durationSamples -= delta\n *\n * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink)\n * - durationSamples += delta\n *\n * @param clip - The clip being trimmed\n * @param deltaSamples - Requested trim delta in samples\n * @param boundary - Which edge is being trimmed: 'left' or 'right'\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the trimmed clip in sortedClips\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns Constrained delta\n */\nexport function constrainBoundaryTrim(\n clip: AudioClip,\n deltaSamples: number,\n boundary: 'left' | 'right',\n sortedClips: AudioClip[],\n clipIndex: number,\n minDurationSamples: number\n): number {\n let delta = deltaSamples;\n\n if (boundary === 'left') {\n // Constraint 1: startSample + delta >= 0\n delta = Math.max(delta, -clip.startSample);\n\n // Constraint 2: offsetSamples + delta >= 0\n delta = Math.max(delta, -clip.offsetSamples);\n\n // Constraint 3: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // startSample + delta >= prevClipEnd\n delta = Math.max(delta, prevClipEnd - clip.startSample);\n }\n\n // Constraint 4: durationSamples - delta >= minDurationSamples\n // delta <= durationSamples - minDurationSamples\n delta = Math.min(delta, clip.durationSamples - minDurationSamples);\n } else {\n // RIGHT boundary\n\n // Constraint 1: durationSamples + delta >= minDurationSamples\n // delta >= minDurationSamples - durationSamples\n delta = Math.max(delta, minDurationSamples - clip.durationSamples);\n\n // Constraint 2: offsetSamples + (durationSamples + delta) <= sourceDurationSamples\n // delta <= sourceDurationSamples - offsetSamples - durationSamples\n delta = Math.min(delta, clip.sourceDurationSamples - clip.offsetSamples - clip.durationSamples);\n\n // Constraint 3: startSample + (durationSamples + delta) <= nextClip.startSample\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // delta <= nextClip.startSample - startSample - durationSamples\n delta = Math.min(delta, nextClip.startSample - clip.startSample - clip.durationSamples);\n }\n }\n\n return delta;\n}\n\n/**\n * Snap a split sample position to the nearest pixel boundary.\n *\n * @param splitSample - The sample position to snap\n * @param samplesPerPixel - Current zoom level (samples per pixel)\n * @returns Snapped sample position\n */\nexport function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number {\n return Math.floor(splitSample / samplesPerPixel) * samplesPerPixel;\n}\n\n/**\n * Split a clip into two clips at the given sample position.\n *\n * The left clip retains the original fadeIn; the right clip retains the original fadeOut.\n * Both clips share the same waveformData reference.\n * If the clip has a name, suffixes \" (1)\" and \" (2)\" are appended.\n *\n * @param clip - The clip to split\n * @param splitSample - The timeline sample position where the split occurs\n * @returns Object with `left` and `right` AudioClip\n */\nexport function splitClip(\n clip: AudioClip,\n splitSample: number\n): { left: AudioClip; right: AudioClip } {\n const leftDuration = splitSample - clip.startSample;\n const rightDuration = clip.durationSamples - leftDuration;\n\n const leftName = clip.name ? `${clip.name} (1)` : undefined;\n const rightName = clip.name ? `${clip.name} (2)` : undefined;\n\n const left = createClip({\n startSample: clip.startSample,\n durationSamples: leftDuration,\n offsetSamples: clip.offsetSamples,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: leftName,\n color: clip.color,\n fadeIn: clip.fadeIn,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n const right = createClip({\n startSample: splitSample,\n durationSamples: rightDuration,\n offsetSamples: clip.offsetSamples + leftDuration,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: rightName,\n color: clip.color,\n fadeOut: clip.fadeOut,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n return { left, right };\n}\n\n/**\n * Check whether a clip can be split at the given sample position.\n *\n * The split point must be strictly inside the clip (not at start or end),\n * and both resulting clips must meet the minimum duration requirement.\n *\n * @param clip - The clip to check\n * @param sample - The timeline sample position to test\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns true if the split is valid\n */\nexport function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean {\n const clipEnd = clip.startSample + clip.durationSamples;\n\n // Must be strictly within clip bounds\n if (sample <= clip.startSample || sample >= clipEnd) {\n return false;\n }\n\n // Both resulting clips must meet minimum duration\n const leftDuration = sample - clip.startSample;\n const rightDuration = clipEnd - sample;\n\n return leftDuration >= minDurationSamples && rightDuration >= minDurationSamples;\n}\n","/**\n * Viewport operations for virtual scrolling.\n *\n * Pure math helpers that determine which portion of the timeline\n * is visible and which canvas chunks need to be mounted.\n */\n\n/**\n * Calculate the visible region with an overscan buffer for virtual scrolling.\n *\n * The buffer extends the visible range on both sides so that chunks are\n * mounted slightly before they scroll into view, preventing flicker.\n *\n * @param scrollLeft - Current horizontal scroll position in pixels\n * @param containerWidth - Width of the scroll container in pixels\n * @param bufferRatio - Multiplier for buffer size (default 1.5x container width)\n * @returns Object with visibleStart and visibleEnd in pixels\n */\nexport function calculateViewportBounds(\n scrollLeft: number,\n containerWidth: number,\n bufferRatio: number = 1.5\n): { visibleStart: number; visibleEnd: number } {\n const buffer = containerWidth * bufferRatio;\n return {\n visibleStart: Math.max(0, scrollLeft - buffer),\n visibleEnd: scrollLeft + containerWidth + buffer,\n };\n}\n\n/**\n * Get an array of chunk indices that overlap the visible viewport.\n *\n * Chunks are fixed-width segments of the total timeline width. Only chunks\n * that intersect [visibleStart, visibleEnd) are included. The last chunk\n * may be narrower than chunkWidth if totalWidth is not evenly divisible.\n *\n * @param totalWidth - Total width of the timeline in pixels\n * @param chunkWidth - Width of each chunk in pixels\n * @param visibleStart - Left edge of the visible region in pixels\n * @param visibleEnd - Right edge of the visible region in pixels\n * @returns Array of chunk indices (0-based) that are visible\n */\nexport function getVisibleChunkIndices(\n totalWidth: number,\n chunkWidth: number,\n visibleStart: number,\n visibleEnd: number\n): number[] {\n const totalChunks = Math.ceil(totalWidth / chunkWidth);\n const indices: number[] = [];\n\n for (let i = 0; i < totalChunks; i++) {\n const chunkLeft = i * chunkWidth;\n const thisChunkWidth = Math.min(totalWidth - chunkLeft, chunkWidth);\n const chunkEnd = chunkLeft + thisChunkWidth;\n\n if (chunkEnd <= visibleStart || chunkLeft >= visibleEnd) {\n continue;\n }\n\n indices.push(i);\n }\n\n return indices;\n}\n\n/**\n * Determine whether a scroll change is large enough to warrant\n * recalculating the viewport and re-rendering chunks.\n *\n * Small scroll movements are ignored to avoid excessive recomputation\n * during smooth scrolling.\n *\n * @param oldScrollLeft - Previous scroll position in pixels\n * @param newScrollLeft - Current scroll position in pixels\n * @param threshold - Minimum pixel delta to trigger an update (default 100)\n * @returns true if the scroll delta meets or exceeds the threshold\n */\nexport function shouldUpdateViewport(\n oldScrollLeft: number,\n newScrollLeft: number,\n threshold: number = 100\n): boolean {\n return Math.abs(oldScrollLeft - newScrollLeft) >= threshold;\n}\n","import type { ClipTrack } from '@waveform-playlist/core';\n\n/**\n * Calculate total timeline duration in seconds from all tracks/clips.\n * Iterates all clips, finds the furthest clip end (startSample + durationSamples),\n * converts to seconds using each clip's sampleRate.\n *\n * @param tracks - Array of clip tracks\n * @returns Duration in seconds\n */\nexport function calculateDuration(tracks: ClipTrack[]): number {\n let maxDuration = 0;\n for (const track of tracks) {\n for (const clip of track.clips) {\n const clipEndSample = clip.startSample + clip.durationSamples;\n const clipEnd = clipEndSample / clip.sampleRate;\n maxDuration = Math.max(maxDuration, clipEnd);\n }\n }\n return maxDuration;\n}\n\n/**\n * Find the zoom level index closest to a given samplesPerPixel.\n * Returns exact match if found, otherwise the index whose value is\n * nearest to the target (by absolute difference).\n *\n * @param targetSamplesPerPixel - The samplesPerPixel value to find\n * @param zoomLevels - Array of available zoom levels (samplesPerPixel values)\n * @returns Index into the zoomLevels array\n */\nexport function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number {\n if (zoomLevels.length === 0) return 0;\n\n let bestIndex = 0;\n let bestDiff = Math.abs(zoomLevels[0] - targetSamplesPerPixel);\n\n for (let i = 1; i < zoomLevels.length; i++) {\n const diff = Math.abs(zoomLevels[i] - targetSamplesPerPixel);\n if (diff < bestDiff) {\n bestDiff = diff;\n bestIndex = i;\n }\n }\n\n return bestIndex;\n}\n\n/**\n * Keep viewport centered during zoom changes.\n * Calculates center time from old zoom, computes new pixel position at new zoom,\n * and returns new scrollLeft clamped to >= 0.\n *\n * @param oldSamplesPerPixel - Previous zoom level\n * @param newSamplesPerPixel - New zoom level\n * @param scrollLeft - Current horizontal scroll position\n * @param containerWidth - Viewport width in pixels\n * @param sampleRate - Audio sample rate\n * @param controlWidth - Width of track controls panel (defaults to 0)\n * @returns New scrollLeft value\n */\nexport function calculateZoomScrollPosition(\n oldSamplesPerPixel: number,\n newSamplesPerPixel: number,\n scrollLeft: number,\n containerWidth: number,\n sampleRate: number,\n controlWidth: number = 0\n): number {\n const centerPixel = scrollLeft + containerWidth / 2 - controlWidth;\n const centerTime = (centerPixel * oldSamplesPerPixel) / sampleRate;\n const newCenterPixel = (centerTime * sampleRate) / newSamplesPerPixel;\n const newScrollLeft = newCenterPixel + controlWidth - containerWidth / 2;\n return Math.max(0, newScrollLeft);\n}\n\n/**\n * Clamp a seek position to the valid range [0, duration].\n *\n * @param time - Requested seek time in seconds\n * @param duration - Maximum duration in seconds\n * @returns Clamped time value\n */\nexport function clampSeekPosition(time: number, duration: number): number {\n return Math.max(0, Math.min(time, duration));\n}\n","/**\n * PlaylistEngine — Stateful, framework-agnostic timeline engine.\n *\n * Composes pure operations from ./operations with an event emitter\n * and optional PlayoutAdapter for audio playback delegation.\n */\n\nimport type { AudioClip, ClipTrack } from '@waveform-playlist/core';\nimport { sortClipsByTime } from '@waveform-playlist/core';\nimport {\n constrainClipDrag,\n constrainBoundaryTrim,\n canSplitAt,\n splitClip as splitClipOp,\n} from './operations/clipOperations';\nimport { calculateDuration, findClosestZoomIndex } from './operations/timelineOperations';\nimport type { PlayoutAdapter, EngineState, EngineEvents, PlaylistEngineOptions } from './types';\n\nconst DEFAULT_SAMPLE_RATE = 48000;\nconst DEFAULT_SAMPLES_PER_PIXEL = 1024;\nconst DEFAULT_ZOOM_LEVELS = [256, 512, 1024, 2048, 4096, 8192];\nconst DEFAULT_MIN_DURATION_SECONDS = 0.1;\n\ntype EventName = keyof EngineEvents;\n\nexport class PlaylistEngine {\n private _tracks: ClipTrack[] = [];\n private _currentTime = 0;\n private _playStartPosition = 0;\n private _isPlaying = false;\n private _selectedTrackId: string | null = null;\n private _sampleRate: number;\n private _zoomLevels: number[];\n private _zoomIndex: number;\n private _selectionStart = 0;\n private _selectionEnd = 0;\n private _masterVolume = 1.0;\n private _loopStart = 0;\n private _loopEnd = 0;\n private _isLoopEnabled = false;\n private _tracksVersion = 0;\n private _adapter: PlayoutAdapter | null;\n private _animFrameId: number | null = null;\n private _disposed = false;\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n private _listeners: Map<string, Set<Function>> = new Map();\n\n private _undoStack: ClipTrack[][] = [];\n private _redoStack: ClipTrack[][] = [];\n private _inTransaction = false;\n private _transactionSnapshot: ClipTrack[] | null = null;\n private _transactionMutated = false;\n readonly undoLimit: number;\n\n constructor(options: PlaylistEngineOptions = {}) {\n this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;\n this._zoomLevels = [...(options.zoomLevels ?? DEFAULT_ZOOM_LEVELS)];\n this._adapter = options.adapter ?? null;\n this.undoLimit = options.undoLimit ?? 100;\n\n if (this._zoomLevels.length === 0) {\n throw new Error('PlaylistEngine: zoomLevels must not be empty');\n }\n\n const initialSpp = options.samplesPerPixel ?? DEFAULT_SAMPLES_PER_PIXEL;\n const zoomIndex = this._zoomLevels.indexOf(initialSpp);\n if (zoomIndex === -1) {\n throw new Error(\n `PlaylistEngine: samplesPerPixel ${initialSpp} is not in zoomLevels [${this._zoomLevels.join(', ')}]. ` +\n `Either pass a samplesPerPixel value that exists in zoomLevels, or include ${initialSpp} in your zoomLevels array.`\n );\n }\n this._zoomIndex = zoomIndex;\n }\n\n // ---------------------------------------------------------------------------\n // Undo/Redo\n // ---------------------------------------------------------------------------\n\n get canUndo(): boolean {\n return this._undoStack.length > 0;\n }\n\n get canRedo(): boolean {\n return this._redoStack.length > 0;\n }\n\n undo(): void {\n if (this._undoStack.length === 0) return;\n const snapshot = this._undoStack.pop()!;\n this._redoStack.push(this._snapshotTracks());\n this._restoreTracks(snapshot);\n }\n\n redo(): void {\n if (this._redoStack.length === 0) return;\n const snapshot = this._redoStack.pop()!;\n this._undoStack.push(this._snapshotTracks());\n this._restoreTracks(snapshot);\n }\n\n clearHistory(): void {\n this._undoStack = [];\n this._redoStack = [];\n }\n\n beginTransaction(): void {\n if (this._inTransaction) {\n console.warn(\n '[waveform-playlist/engine] beginTransaction: already in a transaction, ' +\n 'previous snapshot will be overwritten'\n );\n }\n this._transactionSnapshot = this._snapshotTracks();\n this._inTransaction = true;\n this._transactionMutated = false;\n }\n\n commitTransaction(): void {\n if (!this._inTransaction || this._transactionSnapshot === null) {\n console.warn('[waveform-playlist/engine] commitTransaction: no active transaction to commit');\n return;\n }\n // Only push undo entry if mutations actually occurred during the transaction.\n // Without this, no-op drags (e.g., against a boundary) create phantom undo entries.\n if (this._transactionMutated) {\n this._undoStack.push(this._transactionSnapshot);\n if (this._undoStack.length > this.undoLimit) {\n this._undoStack.shift();\n }\n this._redoStack = [];\n }\n this._transactionSnapshot = null;\n this._inTransaction = false;\n this._transactionMutated = false;\n }\n\n abortTransaction(): void {\n if (!this._inTransaction || this._transactionSnapshot === null) {\n console.warn('[waveform-playlist/engine] abortTransaction: no active transaction to abort');\n return;\n }\n const snapshot = this._transactionSnapshot;\n const mutated = this._transactionMutated;\n this._transactionSnapshot = null;\n this._inTransaction = false;\n this._transactionMutated = false;\n // Only restore if mutations occurred — avoids full adapter rebuild on click\n if (mutated) {\n this._restoreTracks(snapshot);\n }\n }\n\n // ---------------------------------------------------------------------------\n // State snapshot\n // ---------------------------------------------------------------------------\n\n getState(): EngineState {\n return {\n tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),\n tracksVersion: this._tracksVersion,\n duration: calculateDuration(this._tracks),\n currentTime: this._currentTime,\n isPlaying: this._isPlaying,\n samplesPerPixel: this._zoomLevels[this._zoomIndex],\n sampleRate: this._sampleRate,\n selectedTrackId: this._selectedTrackId,\n zoomIndex: this._zoomIndex,\n canZoomIn: this._zoomIndex > 0,\n canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,\n selectionStart: this._selectionStart,\n selectionEnd: this._selectionEnd,\n masterVolume: this._masterVolume,\n loopStart: this._loopStart,\n loopEnd: this._loopEnd,\n isLoopEnabled: this._isLoopEnabled,\n canUndo: this.canUndo,\n canRedo: this.canRedo,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Track Management\n // ---------------------------------------------------------------------------\n\n setTracks(tracks: ClipTrack[]): void {\n this.clearHistory();\n this._tracks = [...tracks];\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n addTrack(track: ClipTrack): void {\n this._pushUndoSnapshot();\n this._tracks = [...this._tracks, track];\n this._tracksVersion++;\n if (this._adapter?.addTrack) {\n this._adapter.addTrack(track);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n removeTrack(trackId: string): void {\n if (!this._tracks.some((t) => t.id === trackId)) return;\n this._pushUndoSnapshot();\n this._tracks = this._tracks.filter((t) => t.id !== trackId);\n this._tracksVersion++;\n if (this._selectedTrackId === trackId) {\n this._selectedTrackId = null;\n }\n if (this._adapter?.removeTrack) {\n this._adapter.removeTrack(trackId);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n /** Update a single track's clips on the adapter (no full rebuild). */\n updateTrack(trackId: string, track?: ClipTrack): void {\n const resolved = track ?? this._tracks.find((t) => t.id === trackId);\n if (!resolved) return;\n if (track) {\n this._pushUndoSnapshot();\n this._tracks = this._tracks.map((t) => (t.id === trackId ? track : t));\n this._tracksVersion++;\n }\n if (this._adapter?.updateTrack) {\n this._adapter.updateTrack(trackId, resolved);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n // Only emit statechange when internal state actually changed\n if (track) this._emitStateChange();\n }\n\n /** Internal: update adapter after modifying this._tracks in place. */\n private _updateTrackOnAdapter(trackId: string): void {\n const t = this._tracks.find((tr) => tr.id === trackId);\n if (!t) return;\n if (this._adapter?.updateTrack) {\n this._adapter.updateTrack(trackId, t);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n }\n\n selectTrack(trackId: string | null): void {\n if (trackId === this._selectedTrackId) return;\n this._selectedTrackId = trackId;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Clip Queries\n // ---------------------------------------------------------------------------\n\n /** Get a clip's full bounds for trim constraint computation. Returns null if not found. */\n getClipBounds(\n trackId: string,\n clipId: string\n ): {\n offsetSamples: number;\n durationSamples: number;\n startSample: number;\n sourceDurationSamples: number;\n } | null {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return null;\n const clip = track.clips.find((c: AudioClip) => c.id === clipId);\n if (!clip) return null;\n return {\n offsetSamples: clip.offsetSamples,\n durationSamples: clip.durationSamples,\n startSample: clip.startSample,\n sourceDurationSamples: clip.sourceDurationSamples,\n };\n }\n\n /** Constrain a trim delta using the engine's collision/bounds logic.\n * Uses the clip's current state and neighboring clips for constraints. */\n constrainTrimDelta(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number\n ): number {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return 0;\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return 0;\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n return constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n }\n\n // ---------------------------------------------------------------------------\n // Clip Editing (delegates to operations/)\n // ---------------------------------------------------------------------------\n\n /** Move a clip by deltaSamples. Returns the constrained delta actually applied (0 if no-op). */\n moveClip(trackId: string, clipId: string, deltaSamples: number, skipAdapter = false): number {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] moveClip: track \"${trackId}\" not found`);\n return 0;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] moveClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return 0;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n\n const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);\n\n if (constrainedDelta === 0) return 0;\n\n this._pushUndoSnapshot();\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) =>\n i === clipIndex\n ? {\n ...c,\n startSample: Math.floor(c.startSample + constrainedDelta),\n }\n : c\n );\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n if (!skipAdapter) {\n this._updateTrackOnAdapter(trackId);\n }\n this._emitStateChange();\n return constrainedDelta;\n }\n\n splitClip(trackId: string, clipId: string, atSample: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] splitClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] splitClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n if (!canSplitAt(clip, atSample, minDuration)) {\n console.warn(\n `[waveform-playlist/engine] splitClip: cannot split clip \"${clipId}\" at sample ${atSample} ` +\n `(clip range: ${clip.startSample}–${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`\n );\n return;\n }\n\n this._pushUndoSnapshot();\n\n const { left, right } = splitClipOp(clip, atSample);\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = [...t.clips];\n newClips.splice(clipIndex, 1, left, right);\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._updateTrackOnAdapter(trackId);\n this._emitStateChange();\n }\n\n trimClip(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number,\n skipAdapter = false\n ): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] trimClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] trimClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n const constrained = constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n\n if (constrained === 0) return;\n\n this._pushUndoSnapshot();\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) => {\n if (i !== clipIndex) return c;\n if (boundary === 'left') {\n return {\n ...c,\n startSample: c.startSample + constrained,\n offsetSamples: c.offsetSamples + constrained,\n durationSamples: c.durationSamples - constrained,\n };\n } else {\n return { ...c, durationSamples: c.durationSamples + constrained };\n }\n });\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n if (!skipAdapter) {\n this._updateTrackOnAdapter(trackId);\n }\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Playback (delegates to adapter, no-ops without adapter)\n // ---------------------------------------------------------------------------\n\n async init(): Promise<void> {\n if (this._adapter) {\n await this._adapter.init();\n }\n }\n\n play(startTime?: number, endTime?: number): void {\n const prevCurrentTime = this._currentTime;\n const prevPlayStartPosition = this._playStartPosition;\n\n if (startTime !== undefined) {\n this._currentTime = Math.max(0, startTime);\n }\n\n // Remember where playback started (Audacity-style: stop returns here)\n this._playStartPosition = this._currentTime;\n\n if (this._adapter) {\n // Configure loop state BEFORE play(). The adapter caches loopStart/\n // loopEnd/enabled from setLoop(), then TonePlayout.play() applies\n // them to the Transport before transport.start() and advances\n // Clock._lastUpdate to skip stale ghost ticks.\n if (endTime !== undefined) {\n // Disable Transport loop for duration-limited playback (selection/annotation)\n this._adapter.setLoop(false, this._loopStart, this._loopEnd);\n } else if (this._isLoopEnabled) {\n // Activate Transport loop if starting before loopEnd. Starting at or\n // past loopEnd plays to the end without looping (click-past-loop behavior).\n const beforeLoopEnd = this._currentTime < this._loopEnd;\n this._adapter.setLoop(beforeLoopEnd, this._loopStart, this._loopEnd);\n }\n try {\n this._adapter.play(this._currentTime, endTime);\n } catch (err) {\n // Restore state so the engine isn't left with a moved playhead\n // but no audio. The throw propagates to the caller.\n this._currentTime = prevCurrentTime;\n this._playStartPosition = prevPlayStartPosition;\n throw err;\n }\n }\n\n this._isPlaying = true;\n this._startTimeUpdateLoop();\n this._emit('play');\n this._emitStateChange();\n }\n\n pause(): void {\n this._isPlaying = false;\n this._stopTimeUpdateLoop();\n this._adapter?.pause();\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n }\n this._emit('pause');\n this._emitStateChange();\n }\n\n stop(): void {\n this._isPlaying = false;\n this._currentTime = this._playStartPosition;\n this._stopTimeUpdateLoop();\n this._adapter?.setLoop(false, this._loopStart, this._loopEnd);\n this._adapter?.stop();\n this._emit('stop');\n this._emitStateChange();\n }\n\n seek(time: number): void {\n this._currentTime = Math.max(0, time);\n this._adapter?.seek(this._currentTime);\n this._emitStateChange();\n }\n\n setMasterVolume(volume: number): void {\n if (volume === this._masterVolume) return;\n this._masterVolume = volume;\n this._adapter?.setMasterVolume(volume);\n this._emitStateChange();\n }\n\n getCurrentTime(): number {\n if (this._isPlaying && this._adapter) {\n return this._adapter.getCurrentTime();\n }\n return this._currentTime;\n }\n\n // ---------------------------------------------------------------------------\n // Selection & Loop\n // ---------------------------------------------------------------------------\n\n setSelection(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._selectionStart && e === this._selectionEnd) return;\n this._selectionStart = s;\n this._selectionEnd = e;\n this._emitStateChange();\n }\n\n setLoopRegion(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._loopStart && e === this._loopEnd) return;\n this._loopStart = s;\n this._loopEnd = e;\n this._adapter?.setLoop(\n this._isLoopEnabled && this._isBeforeLoopEnd(),\n this._loopStart,\n this._loopEnd\n );\n this._emitStateChange();\n }\n\n setLoopEnabled(enabled: boolean): void {\n if (enabled === this._isLoopEnabled) return;\n this._isLoopEnabled = enabled;\n this._adapter?.setLoop(enabled && this._isBeforeLoopEnd(), this._loopStart, this._loopEnd);\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.volume = volume;\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.muted = muted;\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.soloed = soloed;\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.pan = pan;\n this._adapter?.setTrackPan(trackId, pan);\n }\n\n // ---------------------------------------------------------------------------\n // Zoom\n // ---------------------------------------------------------------------------\n\n zoomIn(): void {\n if (this._zoomIndex > 0) {\n this._zoomIndex--;\n this._emitStateChange();\n }\n }\n\n zoomOut(): void {\n if (this._zoomIndex < this._zoomLevels.length - 1) {\n this._zoomIndex++;\n this._emitStateChange();\n }\n }\n\n setZoomLevel(samplesPerPixel: number): void {\n const newIndex = findClosestZoomIndex(samplesPerPixel, this._zoomLevels);\n if (newIndex === this._zoomIndex) return;\n this._zoomIndex = newIndex;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Events\n // ---------------------------------------------------------------------------\n\n on<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n this._listeners.get(event)!.add(listener);\n }\n\n off<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n this._listeners.get(event)?.delete(listener);\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n dispose(): void {\n if (this._disposed) return;\n this._disposed = true;\n this._stopTimeUpdateLoop();\n try {\n this._adapter?.dispose();\n } catch (err) {\n console.warn('[waveform-playlist/engine] Error disposing adapter:', err);\n }\n this._listeners.clear();\n }\n\n // ---------------------------------------------------------------------------\n // Private helpers\n // ---------------------------------------------------------------------------\n\n private _snapshotTracks(): ClipTrack[] {\n return this._tracks.map((t) => ({ ...t, clips: t.clips.map((c) => ({ ...c })) }));\n }\n\n private _pushUndoSnapshot(): void {\n if (this._inTransaction) {\n this._transactionMutated = true;\n return;\n }\n this._undoStack.push(this._snapshotTracks());\n if (this._undoStack.length > this.undoLimit) {\n this._undoStack.shift();\n }\n this._redoStack = [];\n }\n\n private _restoreTracks(snapshot: ClipTrack[]): void {\n const oldTracks = this._tracks;\n this._tracks = snapshot;\n this._tracksVersion++;\n // Use incremental adapter updates when track count is unchanged —\n // avoids full playout rebuild that interrupts playback during undo/redo.\n if (this._adapter && oldTracks.length === snapshot.length) {\n for (let i = 0; i < snapshot.length; i++) {\n if (oldTracks[i] !== snapshot[i]) {\n this._updateTrackOnAdapter(snapshot[i].id);\n }\n }\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n private _emit(event: string, ...args: unknown[]): void {\n const listeners = this._listeners.get(event);\n if (listeners) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch (error) {\n console.warn('[waveform-playlist/engine] Error in event listener:', error);\n }\n }\n }\n }\n\n /**\n * Returns whether the current playback position is before loopEnd.\n * Used by setLoopEnabled/setLoopRegion during playback — if past loopEnd,\n * Transport loop stays off so playback continues to the end.\n * Note: play() uses an inline check instead — _isPlaying is still false\n * when play() runs, and this method returns true unconditionally when\n * not playing.\n */\n private _isBeforeLoopEnd(): boolean {\n if (!this._isPlaying) return true;\n const t = this._adapter?.getCurrentTime() ?? this._currentTime;\n return t < this._loopEnd;\n }\n\n private _emitStateChange(): void {\n this._emit('statechange', this.getState());\n }\n\n private _startTimeUpdateLoop(): void {\n // Guard for Node.js / SSR environments where RAF is unavailable\n if (typeof requestAnimationFrame === 'undefined') return;\n\n this._stopTimeUpdateLoop();\n\n const tick = () => {\n if (this._disposed || !this._isPlaying) return;\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n this._emit('timeupdate', this._currentTime);\n }\n this._animFrameId = requestAnimationFrame(tick);\n };\n\n this._animFrameId = requestAnimationFrame(tick);\n }\n\n private _stopTimeUpdateLoop(): void {\n if (this._animFrameId !== null && typeof cancelAnimationFrame !== 'undefined') {\n cancelAnimationFrame(this._animFrameId);\n this._animFrameId = null;\n }\n }\n}\n"],"mappings":"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","names":["import_core"]}
|
package/dist/index.mjs
CHANGED
|
@@ -170,9 +170,15 @@ var PlaylistEngine = class {
|
|
|
170
170
|
this._disposed = false;
|
|
171
171
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
|
172
172
|
this._listeners = /* @__PURE__ */ new Map();
|
|
173
|
+
this._undoStack = [];
|
|
174
|
+
this._redoStack = [];
|
|
175
|
+
this._inTransaction = false;
|
|
176
|
+
this._transactionSnapshot = null;
|
|
177
|
+
this._transactionMutated = false;
|
|
173
178
|
this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
|
174
179
|
this._zoomLevels = [...options.zoomLevels ?? DEFAULT_ZOOM_LEVELS];
|
|
175
180
|
this._adapter = options.adapter ?? null;
|
|
181
|
+
this.undoLimit = options.undoLimit ?? 100;
|
|
176
182
|
if (this._zoomLevels.length === 0) {
|
|
177
183
|
throw new Error("PlaylistEngine: zoomLevels must not be empty");
|
|
178
184
|
}
|
|
@@ -186,6 +192,71 @@ var PlaylistEngine = class {
|
|
|
186
192
|
this._zoomIndex = zoomIndex;
|
|
187
193
|
}
|
|
188
194
|
// ---------------------------------------------------------------------------
|
|
195
|
+
// Undo/Redo
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
get canUndo() {
|
|
198
|
+
return this._undoStack.length > 0;
|
|
199
|
+
}
|
|
200
|
+
get canRedo() {
|
|
201
|
+
return this._redoStack.length > 0;
|
|
202
|
+
}
|
|
203
|
+
undo() {
|
|
204
|
+
if (this._undoStack.length === 0) return;
|
|
205
|
+
const snapshot = this._undoStack.pop();
|
|
206
|
+
this._redoStack.push(this._snapshotTracks());
|
|
207
|
+
this._restoreTracks(snapshot);
|
|
208
|
+
}
|
|
209
|
+
redo() {
|
|
210
|
+
if (this._redoStack.length === 0) return;
|
|
211
|
+
const snapshot = this._redoStack.pop();
|
|
212
|
+
this._undoStack.push(this._snapshotTracks());
|
|
213
|
+
this._restoreTracks(snapshot);
|
|
214
|
+
}
|
|
215
|
+
clearHistory() {
|
|
216
|
+
this._undoStack = [];
|
|
217
|
+
this._redoStack = [];
|
|
218
|
+
}
|
|
219
|
+
beginTransaction() {
|
|
220
|
+
if (this._inTransaction) {
|
|
221
|
+
console.warn(
|
|
222
|
+
"[waveform-playlist/engine] beginTransaction: already in a transaction, previous snapshot will be overwritten"
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
this._transactionSnapshot = this._snapshotTracks();
|
|
226
|
+
this._inTransaction = true;
|
|
227
|
+
this._transactionMutated = false;
|
|
228
|
+
}
|
|
229
|
+
commitTransaction() {
|
|
230
|
+
if (!this._inTransaction || this._transactionSnapshot === null) {
|
|
231
|
+
console.warn("[waveform-playlist/engine] commitTransaction: no active transaction to commit");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (this._transactionMutated) {
|
|
235
|
+
this._undoStack.push(this._transactionSnapshot);
|
|
236
|
+
if (this._undoStack.length > this.undoLimit) {
|
|
237
|
+
this._undoStack.shift();
|
|
238
|
+
}
|
|
239
|
+
this._redoStack = [];
|
|
240
|
+
}
|
|
241
|
+
this._transactionSnapshot = null;
|
|
242
|
+
this._inTransaction = false;
|
|
243
|
+
this._transactionMutated = false;
|
|
244
|
+
}
|
|
245
|
+
abortTransaction() {
|
|
246
|
+
if (!this._inTransaction || this._transactionSnapshot === null) {
|
|
247
|
+
console.warn("[waveform-playlist/engine] abortTransaction: no active transaction to abort");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const snapshot = this._transactionSnapshot;
|
|
251
|
+
const mutated = this._transactionMutated;
|
|
252
|
+
this._transactionSnapshot = null;
|
|
253
|
+
this._inTransaction = false;
|
|
254
|
+
this._transactionMutated = false;
|
|
255
|
+
if (mutated) {
|
|
256
|
+
this._restoreTracks(snapshot);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
189
260
|
// State snapshot
|
|
190
261
|
// ---------------------------------------------------------------------------
|
|
191
262
|
getState() {
|
|
@@ -206,19 +277,23 @@ var PlaylistEngine = class {
|
|
|
206
277
|
masterVolume: this._masterVolume,
|
|
207
278
|
loopStart: this._loopStart,
|
|
208
279
|
loopEnd: this._loopEnd,
|
|
209
|
-
isLoopEnabled: this._isLoopEnabled
|
|
280
|
+
isLoopEnabled: this._isLoopEnabled,
|
|
281
|
+
canUndo: this.canUndo,
|
|
282
|
+
canRedo: this.canRedo
|
|
210
283
|
};
|
|
211
284
|
}
|
|
212
285
|
// ---------------------------------------------------------------------------
|
|
213
286
|
// Track Management
|
|
214
287
|
// ---------------------------------------------------------------------------
|
|
215
288
|
setTracks(tracks) {
|
|
289
|
+
this.clearHistory();
|
|
216
290
|
this._tracks = [...tracks];
|
|
217
291
|
this._tracksVersion++;
|
|
218
292
|
this._adapter?.setTracks(this._tracks);
|
|
219
293
|
this._emitStateChange();
|
|
220
294
|
}
|
|
221
295
|
addTrack(track) {
|
|
296
|
+
this._pushUndoSnapshot();
|
|
222
297
|
this._tracks = [...this._tracks, track];
|
|
223
298
|
this._tracksVersion++;
|
|
224
299
|
if (this._adapter?.addTrack) {
|
|
@@ -230,6 +305,7 @@ var PlaylistEngine = class {
|
|
|
230
305
|
}
|
|
231
306
|
removeTrack(trackId) {
|
|
232
307
|
if (!this._tracks.some((t) => t.id === trackId)) return;
|
|
308
|
+
this._pushUndoSnapshot();
|
|
233
309
|
this._tracks = this._tracks.filter((t) => t.id !== trackId);
|
|
234
310
|
this._tracksVersion++;
|
|
235
311
|
if (this._selectedTrackId === trackId) {
|
|
@@ -247,6 +323,7 @@ var PlaylistEngine = class {
|
|
|
247
323
|
const resolved = track ?? this._tracks.find((t) => t.id === trackId);
|
|
248
324
|
if (!resolved) return;
|
|
249
325
|
if (track) {
|
|
326
|
+
this._pushUndoSnapshot();
|
|
250
327
|
this._tracks = this._tracks.map((t) => t.id === trackId ? track : t);
|
|
251
328
|
this._tracksVersion++;
|
|
252
329
|
}
|
|
@@ -311,24 +388,26 @@ var PlaylistEngine = class {
|
|
|
311
388
|
// ---------------------------------------------------------------------------
|
|
312
389
|
// Clip Editing (delegates to operations/)
|
|
313
390
|
// ---------------------------------------------------------------------------
|
|
391
|
+
/** Move a clip by deltaSamples. Returns the constrained delta actually applied (0 if no-op). */
|
|
314
392
|
moveClip(trackId, clipId, deltaSamples, skipAdapter = false) {
|
|
315
393
|
const track = this._tracks.find((t) => t.id === trackId);
|
|
316
394
|
if (!track) {
|
|
317
395
|
console.warn(`[waveform-playlist/engine] moveClip: track "${trackId}" not found`);
|
|
318
|
-
return;
|
|
396
|
+
return 0;
|
|
319
397
|
}
|
|
320
398
|
const clipIndex = track.clips.findIndex((c) => c.id === clipId);
|
|
321
399
|
if (clipIndex === -1) {
|
|
322
400
|
console.warn(
|
|
323
401
|
`[waveform-playlist/engine] moveClip: clip "${clipId}" not found in track "${trackId}"`
|
|
324
402
|
);
|
|
325
|
-
return;
|
|
403
|
+
return 0;
|
|
326
404
|
}
|
|
327
405
|
const clip = track.clips[clipIndex];
|
|
328
406
|
const sortedClips = sortClipsByTime(track.clips);
|
|
329
407
|
const sortedIndex = sortedClips.findIndex((c) => c.id === clipId);
|
|
330
408
|
const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);
|
|
331
|
-
if (constrainedDelta === 0) return;
|
|
409
|
+
if (constrainedDelta === 0) return 0;
|
|
410
|
+
this._pushUndoSnapshot();
|
|
332
411
|
this._tracks = this._tracks.map((t) => {
|
|
333
412
|
if (t.id !== trackId) return t;
|
|
334
413
|
const newClips = t.clips.map(
|
|
@@ -344,6 +423,7 @@ var PlaylistEngine = class {
|
|
|
344
423
|
this._updateTrackOnAdapter(trackId);
|
|
345
424
|
}
|
|
346
425
|
this._emitStateChange();
|
|
426
|
+
return constrainedDelta;
|
|
347
427
|
}
|
|
348
428
|
splitClip(trackId, clipId, atSample) {
|
|
349
429
|
const track = this._tracks.find((t) => t.id === trackId);
|
|
@@ -366,6 +446,7 @@ var PlaylistEngine = class {
|
|
|
366
446
|
);
|
|
367
447
|
return;
|
|
368
448
|
}
|
|
449
|
+
this._pushUndoSnapshot();
|
|
369
450
|
const { left, right } = splitClip(clip, atSample);
|
|
370
451
|
this._tracks = this._tracks.map((t) => {
|
|
371
452
|
if (t.id !== trackId) return t;
|
|
@@ -403,6 +484,7 @@ var PlaylistEngine = class {
|
|
|
403
484
|
minDuration
|
|
404
485
|
);
|
|
405
486
|
if (constrained === 0) return;
|
|
487
|
+
this._pushUndoSnapshot();
|
|
406
488
|
this._tracks = this._tracks.map((t) => {
|
|
407
489
|
if (t.id !== trackId) return t;
|
|
408
490
|
const newClips = t.clips.map((c, i) => {
|
|
@@ -600,6 +682,35 @@ var PlaylistEngine = class {
|
|
|
600
682
|
// ---------------------------------------------------------------------------
|
|
601
683
|
// Private helpers
|
|
602
684
|
// ---------------------------------------------------------------------------
|
|
685
|
+
_snapshotTracks() {
|
|
686
|
+
return this._tracks.map((t) => ({ ...t, clips: t.clips.map((c) => ({ ...c })) }));
|
|
687
|
+
}
|
|
688
|
+
_pushUndoSnapshot() {
|
|
689
|
+
if (this._inTransaction) {
|
|
690
|
+
this._transactionMutated = true;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
this._undoStack.push(this._snapshotTracks());
|
|
694
|
+
if (this._undoStack.length > this.undoLimit) {
|
|
695
|
+
this._undoStack.shift();
|
|
696
|
+
}
|
|
697
|
+
this._redoStack = [];
|
|
698
|
+
}
|
|
699
|
+
_restoreTracks(snapshot) {
|
|
700
|
+
const oldTracks = this._tracks;
|
|
701
|
+
this._tracks = snapshot;
|
|
702
|
+
this._tracksVersion++;
|
|
703
|
+
if (this._adapter && oldTracks.length === snapshot.length) {
|
|
704
|
+
for (let i = 0; i < snapshot.length; i++) {
|
|
705
|
+
if (oldTracks[i] !== snapshot[i]) {
|
|
706
|
+
this._updateTrackOnAdapter(snapshot[i].id);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
} else {
|
|
710
|
+
this._adapter?.setTracks(this._tracks);
|
|
711
|
+
}
|
|
712
|
+
this._emitStateChange();
|
|
713
|
+
}
|
|
603
714
|
_emit(event, ...args) {
|
|
604
715
|
const listeners = this._listeners.get(event);
|
|
605
716
|
if (listeners) {
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/operations/clipOperations.ts","../src/operations/viewportOperations.ts","../src/operations/timelineOperations.ts","../src/PlaylistEngine.ts"],"sourcesContent":["/**\n * Clip Operations\n *\n * Pure functions for constraining clip movement, boundary trimming,\n * and splitting clips on a timeline. All positions are in samples (integers).\n */\n\nimport type { AudioClip } from '@waveform-playlist/core';\nimport { createClip } from '@waveform-playlist/core';\n\n/**\n * Constrain clip movement delta to prevent overlaps with adjacent clips\n * and going before sample 0.\n *\n * @param clip - The clip being dragged\n * @param deltaSamples - Requested movement in samples (negative = left, positive = right)\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the dragged clip in sortedClips\n * @returns Constrained delta that prevents overlaps\n */\nexport function constrainClipDrag(\n clip: AudioClip,\n deltaSamples: number,\n sortedClips: AudioClip[],\n clipIndex: number\n): number {\n let delta = deltaSamples;\n\n // Constraint 1: Cannot go before sample 0\n const minDelta = -clip.startSample;\n delta = Math.max(delta, minDelta);\n\n // Constraint 2: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // clip.startSample + delta >= prevClipEnd\n const minDeltaPrev = prevClipEnd - clip.startSample;\n delta = Math.max(delta, minDeltaPrev);\n }\n\n // Constraint 3: Cannot overlap next clip\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // clip.startSample + clip.durationSamples + delta <= nextClip.startSample\n const maxDeltaNext = nextClip.startSample - (clip.startSample + clip.durationSamples);\n delta = Math.min(delta, maxDeltaNext);\n }\n\n return delta;\n}\n\n/**\n * Constrain boundary trim delta for left or right edge of a clip.\n *\n * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand)\n * - startSample += delta, offsetSamples += delta, durationSamples -= delta\n *\n * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink)\n * - durationSamples += delta\n *\n * @param clip - The clip being trimmed\n * @param deltaSamples - Requested trim delta in samples\n * @param boundary - Which edge is being trimmed: 'left' or 'right'\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the trimmed clip in sortedClips\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns Constrained delta\n */\nexport function constrainBoundaryTrim(\n clip: AudioClip,\n deltaSamples: number,\n boundary: 'left' | 'right',\n sortedClips: AudioClip[],\n clipIndex: number,\n minDurationSamples: number\n): number {\n let delta = deltaSamples;\n\n if (boundary === 'left') {\n // Constraint 1: startSample + delta >= 0\n delta = Math.max(delta, -clip.startSample);\n\n // Constraint 2: offsetSamples + delta >= 0\n delta = Math.max(delta, -clip.offsetSamples);\n\n // Constraint 3: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // startSample + delta >= prevClipEnd\n delta = Math.max(delta, prevClipEnd - clip.startSample);\n }\n\n // Constraint 4: durationSamples - delta >= minDurationSamples\n // delta <= durationSamples - minDurationSamples\n delta = Math.min(delta, clip.durationSamples - minDurationSamples);\n } else {\n // RIGHT boundary\n\n // Constraint 1: durationSamples + delta >= minDurationSamples\n // delta >= minDurationSamples - durationSamples\n delta = Math.max(delta, minDurationSamples - clip.durationSamples);\n\n // Constraint 2: offsetSamples + (durationSamples + delta) <= sourceDurationSamples\n // delta <= sourceDurationSamples - offsetSamples - durationSamples\n delta = Math.min(delta, clip.sourceDurationSamples - clip.offsetSamples - clip.durationSamples);\n\n // Constraint 3: startSample + (durationSamples + delta) <= nextClip.startSample\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // delta <= nextClip.startSample - startSample - durationSamples\n delta = Math.min(delta, nextClip.startSample - clip.startSample - clip.durationSamples);\n }\n }\n\n return delta;\n}\n\n/**\n * Snap a split sample position to the nearest pixel boundary.\n *\n * @param splitSample - The sample position to snap\n * @param samplesPerPixel - Current zoom level (samples per pixel)\n * @returns Snapped sample position\n */\nexport function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number {\n return Math.floor(splitSample / samplesPerPixel) * samplesPerPixel;\n}\n\n/**\n * Split a clip into two clips at the given sample position.\n *\n * The left clip retains the original fadeIn; the right clip retains the original fadeOut.\n * Both clips share the same waveformData reference.\n * If the clip has a name, suffixes \" (1)\" and \" (2)\" are appended.\n *\n * @param clip - The clip to split\n * @param splitSample - The timeline sample position where the split occurs\n * @returns Object with `left` and `right` AudioClip\n */\nexport function splitClip(\n clip: AudioClip,\n splitSample: number\n): { left: AudioClip; right: AudioClip } {\n const leftDuration = splitSample - clip.startSample;\n const rightDuration = clip.durationSamples - leftDuration;\n\n const leftName = clip.name ? `${clip.name} (1)` : undefined;\n const rightName = clip.name ? `${clip.name} (2)` : undefined;\n\n const left = createClip({\n startSample: clip.startSample,\n durationSamples: leftDuration,\n offsetSamples: clip.offsetSamples,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: leftName,\n color: clip.color,\n fadeIn: clip.fadeIn,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n const right = createClip({\n startSample: splitSample,\n durationSamples: rightDuration,\n offsetSamples: clip.offsetSamples + leftDuration,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: rightName,\n color: clip.color,\n fadeOut: clip.fadeOut,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n return { left, right };\n}\n\n/**\n * Check whether a clip can be split at the given sample position.\n *\n * The split point must be strictly inside the clip (not at start or end),\n * and both resulting clips must meet the minimum duration requirement.\n *\n * @param clip - The clip to check\n * @param sample - The timeline sample position to test\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns true if the split is valid\n */\nexport function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean {\n const clipEnd = clip.startSample + clip.durationSamples;\n\n // Must be strictly within clip bounds\n if (sample <= clip.startSample || sample >= clipEnd) {\n return false;\n }\n\n // Both resulting clips must meet minimum duration\n const leftDuration = sample - clip.startSample;\n const rightDuration = clipEnd - sample;\n\n return leftDuration >= minDurationSamples && rightDuration >= minDurationSamples;\n}\n","/**\n * Viewport operations for virtual scrolling.\n *\n * Pure math helpers that determine which portion of the timeline\n * is visible and which canvas chunks need to be mounted.\n */\n\n/**\n * Calculate the visible region with an overscan buffer for virtual scrolling.\n *\n * The buffer extends the visible range on both sides so that chunks are\n * mounted slightly before they scroll into view, preventing flicker.\n *\n * @param scrollLeft - Current horizontal scroll position in pixels\n * @param containerWidth - Width of the scroll container in pixels\n * @param bufferRatio - Multiplier for buffer size (default 1.5x container width)\n * @returns Object with visibleStart and visibleEnd in pixels\n */\nexport function calculateViewportBounds(\n scrollLeft: number,\n containerWidth: number,\n bufferRatio: number = 1.5\n): { visibleStart: number; visibleEnd: number } {\n const buffer = containerWidth * bufferRatio;\n return {\n visibleStart: Math.max(0, scrollLeft - buffer),\n visibleEnd: scrollLeft + containerWidth + buffer,\n };\n}\n\n/**\n * Get an array of chunk indices that overlap the visible viewport.\n *\n * Chunks are fixed-width segments of the total timeline width. Only chunks\n * that intersect [visibleStart, visibleEnd) are included. The last chunk\n * may be narrower than chunkWidth if totalWidth is not evenly divisible.\n *\n * @param totalWidth - Total width of the timeline in pixels\n * @param chunkWidth - Width of each chunk in pixels\n * @param visibleStart - Left edge of the visible region in pixels\n * @param visibleEnd - Right edge of the visible region in pixels\n * @returns Array of chunk indices (0-based) that are visible\n */\nexport function getVisibleChunkIndices(\n totalWidth: number,\n chunkWidth: number,\n visibleStart: number,\n visibleEnd: number\n): number[] {\n const totalChunks = Math.ceil(totalWidth / chunkWidth);\n const indices: number[] = [];\n\n for (let i = 0; i < totalChunks; i++) {\n const chunkLeft = i * chunkWidth;\n const thisChunkWidth = Math.min(totalWidth - chunkLeft, chunkWidth);\n const chunkEnd = chunkLeft + thisChunkWidth;\n\n if (chunkEnd <= visibleStart || chunkLeft >= visibleEnd) {\n continue;\n }\n\n indices.push(i);\n }\n\n return indices;\n}\n\n/**\n * Determine whether a scroll change is large enough to warrant\n * recalculating the viewport and re-rendering chunks.\n *\n * Small scroll movements are ignored to avoid excessive recomputation\n * during smooth scrolling.\n *\n * @param oldScrollLeft - Previous scroll position in pixels\n * @param newScrollLeft - Current scroll position in pixels\n * @param threshold - Minimum pixel delta to trigger an update (default 100)\n * @returns true if the scroll delta meets or exceeds the threshold\n */\nexport function shouldUpdateViewport(\n oldScrollLeft: number,\n newScrollLeft: number,\n threshold: number = 100\n): boolean {\n return Math.abs(oldScrollLeft - newScrollLeft) >= threshold;\n}\n","import type { ClipTrack } from '@waveform-playlist/core';\n\n/**\n * Calculate total timeline duration in seconds from all tracks/clips.\n * Iterates all clips, finds the furthest clip end (startSample + durationSamples),\n * converts to seconds using each clip's sampleRate.\n *\n * @param tracks - Array of clip tracks\n * @returns Duration in seconds\n */\nexport function calculateDuration(tracks: ClipTrack[]): number {\n let maxDuration = 0;\n for (const track of tracks) {\n for (const clip of track.clips) {\n const clipEndSample = clip.startSample + clip.durationSamples;\n const clipEnd = clipEndSample / clip.sampleRate;\n maxDuration = Math.max(maxDuration, clipEnd);\n }\n }\n return maxDuration;\n}\n\n/**\n * Find the zoom level index closest to a given samplesPerPixel.\n * Returns exact match if found, otherwise the index whose value is\n * nearest to the target (by absolute difference).\n *\n * @param targetSamplesPerPixel - The samplesPerPixel value to find\n * @param zoomLevels - Array of available zoom levels (samplesPerPixel values)\n * @returns Index into the zoomLevels array\n */\nexport function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number {\n if (zoomLevels.length === 0) return 0;\n\n let bestIndex = 0;\n let bestDiff = Math.abs(zoomLevels[0] - targetSamplesPerPixel);\n\n for (let i = 1; i < zoomLevels.length; i++) {\n const diff = Math.abs(zoomLevels[i] - targetSamplesPerPixel);\n if (diff < bestDiff) {\n bestDiff = diff;\n bestIndex = i;\n }\n }\n\n return bestIndex;\n}\n\n/**\n * Keep viewport centered during zoom changes.\n * Calculates center time from old zoom, computes new pixel position at new zoom,\n * and returns new scrollLeft clamped to >= 0.\n *\n * @param oldSamplesPerPixel - Previous zoom level\n * @param newSamplesPerPixel - New zoom level\n * @param scrollLeft - Current horizontal scroll position\n * @param containerWidth - Viewport width in pixels\n * @param sampleRate - Audio sample rate\n * @param controlWidth - Width of track controls panel (defaults to 0)\n * @returns New scrollLeft value\n */\nexport function calculateZoomScrollPosition(\n oldSamplesPerPixel: number,\n newSamplesPerPixel: number,\n scrollLeft: number,\n containerWidth: number,\n sampleRate: number,\n controlWidth: number = 0\n): number {\n const centerPixel = scrollLeft + containerWidth / 2 - controlWidth;\n const centerTime = (centerPixel * oldSamplesPerPixel) / sampleRate;\n const newCenterPixel = (centerTime * sampleRate) / newSamplesPerPixel;\n const newScrollLeft = newCenterPixel + controlWidth - containerWidth / 2;\n return Math.max(0, newScrollLeft);\n}\n\n/**\n * Clamp a seek position to the valid range [0, duration].\n *\n * @param time - Requested seek time in seconds\n * @param duration - Maximum duration in seconds\n * @returns Clamped time value\n */\nexport function clampSeekPosition(time: number, duration: number): number {\n return Math.max(0, Math.min(time, duration));\n}\n","/**\n * PlaylistEngine — Stateful, framework-agnostic timeline engine.\n *\n * Composes pure operations from ./operations with an event emitter\n * and optional PlayoutAdapter for audio playback delegation.\n */\n\nimport type { AudioClip, ClipTrack } from '@waveform-playlist/core';\nimport { sortClipsByTime } from '@waveform-playlist/core';\nimport {\n constrainClipDrag,\n constrainBoundaryTrim,\n canSplitAt,\n splitClip as splitClipOp,\n} from './operations/clipOperations';\nimport { calculateDuration, findClosestZoomIndex } from './operations/timelineOperations';\nimport type { PlayoutAdapter, EngineState, EngineEvents, PlaylistEngineOptions } from './types';\n\nconst DEFAULT_SAMPLE_RATE = 48000;\nconst DEFAULT_SAMPLES_PER_PIXEL = 1024;\nconst DEFAULT_ZOOM_LEVELS = [256, 512, 1024, 2048, 4096, 8192];\nconst DEFAULT_MIN_DURATION_SECONDS = 0.1;\n\ntype EventName = keyof EngineEvents;\n\nexport class PlaylistEngine {\n private _tracks: ClipTrack[] = [];\n private _currentTime = 0;\n private _playStartPosition = 0;\n private _isPlaying = false;\n private _selectedTrackId: string | null = null;\n private _sampleRate: number;\n private _zoomLevels: number[];\n private _zoomIndex: number;\n private _selectionStart = 0;\n private _selectionEnd = 0;\n private _masterVolume = 1.0;\n private _loopStart = 0;\n private _loopEnd = 0;\n private _isLoopEnabled = false;\n private _tracksVersion = 0;\n private _adapter: PlayoutAdapter | null;\n private _animFrameId: number | null = null;\n private _disposed = false;\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n private _listeners: Map<string, Set<Function>> = new Map();\n\n constructor(options: PlaylistEngineOptions = {}) {\n this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;\n this._zoomLevels = [...(options.zoomLevels ?? DEFAULT_ZOOM_LEVELS)];\n this._adapter = options.adapter ?? null;\n\n if (this._zoomLevels.length === 0) {\n throw new Error('PlaylistEngine: zoomLevels must not be empty');\n }\n\n const initialSpp = options.samplesPerPixel ?? DEFAULT_SAMPLES_PER_PIXEL;\n const zoomIndex = this._zoomLevels.indexOf(initialSpp);\n if (zoomIndex === -1) {\n throw new Error(\n `PlaylistEngine: samplesPerPixel ${initialSpp} is not in zoomLevels [${this._zoomLevels.join(', ')}]. ` +\n `Either pass a samplesPerPixel value that exists in zoomLevels, or include ${initialSpp} in your zoomLevels array.`\n );\n }\n this._zoomIndex = zoomIndex;\n }\n\n // ---------------------------------------------------------------------------\n // State snapshot\n // ---------------------------------------------------------------------------\n\n getState(): EngineState {\n return {\n tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),\n tracksVersion: this._tracksVersion,\n duration: calculateDuration(this._tracks),\n currentTime: this._currentTime,\n isPlaying: this._isPlaying,\n samplesPerPixel: this._zoomLevels[this._zoomIndex],\n sampleRate: this._sampleRate,\n selectedTrackId: this._selectedTrackId,\n zoomIndex: this._zoomIndex,\n canZoomIn: this._zoomIndex > 0,\n canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,\n selectionStart: this._selectionStart,\n selectionEnd: this._selectionEnd,\n masterVolume: this._masterVolume,\n loopStart: this._loopStart,\n loopEnd: this._loopEnd,\n isLoopEnabled: this._isLoopEnabled,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Track Management\n // ---------------------------------------------------------------------------\n\n setTracks(tracks: ClipTrack[]): void {\n this._tracks = [...tracks];\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n addTrack(track: ClipTrack): void {\n this._tracks = [...this._tracks, track];\n this._tracksVersion++;\n if (this._adapter?.addTrack) {\n this._adapter.addTrack(track);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n removeTrack(trackId: string): void {\n if (!this._tracks.some((t) => t.id === trackId)) return;\n this._tracks = this._tracks.filter((t) => t.id !== trackId);\n this._tracksVersion++;\n if (this._selectedTrackId === trackId) {\n this._selectedTrackId = null;\n }\n if (this._adapter?.removeTrack) {\n this._adapter.removeTrack(trackId);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n /** Update a single track's clips on the adapter (no full rebuild). */\n updateTrack(trackId: string, track?: ClipTrack): void {\n const resolved = track ?? this._tracks.find((t) => t.id === trackId);\n if (!resolved) return;\n if (track) {\n this._tracks = this._tracks.map((t) => (t.id === trackId ? track : t));\n this._tracksVersion++;\n }\n if (this._adapter?.updateTrack) {\n this._adapter.updateTrack(trackId, resolved);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n // Only emit statechange when internal state actually changed\n if (track) this._emitStateChange();\n }\n\n /** Internal: update adapter after modifying this._tracks in place. */\n private _updateTrackOnAdapter(trackId: string): void {\n const t = this._tracks.find((tr) => tr.id === trackId);\n if (!t) return;\n if (this._adapter?.updateTrack) {\n this._adapter.updateTrack(trackId, t);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n }\n\n selectTrack(trackId: string | null): void {\n if (trackId === this._selectedTrackId) return;\n this._selectedTrackId = trackId;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Clip Queries\n // ---------------------------------------------------------------------------\n\n /** Get a clip's full bounds for trim constraint computation. Returns null if not found. */\n getClipBounds(\n trackId: string,\n clipId: string\n ): {\n offsetSamples: number;\n durationSamples: number;\n startSample: number;\n sourceDurationSamples: number;\n } | null {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return null;\n const clip = track.clips.find((c: AudioClip) => c.id === clipId);\n if (!clip) return null;\n return {\n offsetSamples: clip.offsetSamples,\n durationSamples: clip.durationSamples,\n startSample: clip.startSample,\n sourceDurationSamples: clip.sourceDurationSamples,\n };\n }\n\n /** Constrain a trim delta using the engine's collision/bounds logic.\n * Uses the clip's current state and neighboring clips for constraints. */\n constrainTrimDelta(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number\n ): number {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return 0;\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return 0;\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n return constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n }\n\n // ---------------------------------------------------------------------------\n // Clip Editing (delegates to operations/)\n // ---------------------------------------------------------------------------\n\n moveClip(trackId: string, clipId: string, deltaSamples: number, skipAdapter = false): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] moveClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] moveClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n\n const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);\n\n if (constrainedDelta === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) =>\n i === clipIndex\n ? {\n ...c,\n startSample: Math.floor(c.startSample + constrainedDelta),\n }\n : c\n );\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n if (!skipAdapter) {\n this._updateTrackOnAdapter(trackId);\n }\n this._emitStateChange();\n }\n\n splitClip(trackId: string, clipId: string, atSample: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] splitClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] splitClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n if (!canSplitAt(clip, atSample, minDuration)) {\n console.warn(\n `[waveform-playlist/engine] splitClip: cannot split clip \"${clipId}\" at sample ${atSample} ` +\n `(clip range: ${clip.startSample}–${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`\n );\n return;\n }\n\n const { left, right } = splitClipOp(clip, atSample);\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = [...t.clips];\n newClips.splice(clipIndex, 1, left, right);\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._updateTrackOnAdapter(trackId);\n this._emitStateChange();\n }\n\n trimClip(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number,\n skipAdapter = false\n ): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] trimClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] trimClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n const constrained = constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n\n if (constrained === 0) return;\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) => {\n if (i !== clipIndex) return c;\n if (boundary === 'left') {\n return {\n ...c,\n startSample: c.startSample + constrained,\n offsetSamples: c.offsetSamples + constrained,\n durationSamples: c.durationSamples - constrained,\n };\n } else {\n return { ...c, durationSamples: c.durationSamples + constrained };\n }\n });\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n if (!skipAdapter) {\n this._updateTrackOnAdapter(trackId);\n }\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Playback (delegates to adapter, no-ops without adapter)\n // ---------------------------------------------------------------------------\n\n async init(): Promise<void> {\n if (this._adapter) {\n await this._adapter.init();\n }\n }\n\n play(startTime?: number, endTime?: number): void {\n const prevCurrentTime = this._currentTime;\n const prevPlayStartPosition = this._playStartPosition;\n\n if (startTime !== undefined) {\n this._currentTime = Math.max(0, startTime);\n }\n\n // Remember where playback started (Audacity-style: stop returns here)\n this._playStartPosition = this._currentTime;\n\n if (this._adapter) {\n // Configure loop state BEFORE play(). The adapter caches loopStart/\n // loopEnd/enabled from setLoop(), then TonePlayout.play() applies\n // them to the Transport before transport.start() and advances\n // Clock._lastUpdate to skip stale ghost ticks.\n if (endTime !== undefined) {\n // Disable Transport loop for duration-limited playback (selection/annotation)\n this._adapter.setLoop(false, this._loopStart, this._loopEnd);\n } else if (this._isLoopEnabled) {\n // Activate Transport loop if starting before loopEnd. Starting at or\n // past loopEnd plays to the end without looping (click-past-loop behavior).\n const beforeLoopEnd = this._currentTime < this._loopEnd;\n this._adapter.setLoop(beforeLoopEnd, this._loopStart, this._loopEnd);\n }\n try {\n this._adapter.play(this._currentTime, endTime);\n } catch (err) {\n // Restore state so the engine isn't left with a moved playhead\n // but no audio. The throw propagates to the caller.\n this._currentTime = prevCurrentTime;\n this._playStartPosition = prevPlayStartPosition;\n throw err;\n }\n }\n\n this._isPlaying = true;\n this._startTimeUpdateLoop();\n this._emit('play');\n this._emitStateChange();\n }\n\n pause(): void {\n this._isPlaying = false;\n this._stopTimeUpdateLoop();\n this._adapter?.pause();\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n }\n this._emit('pause');\n this._emitStateChange();\n }\n\n stop(): void {\n this._isPlaying = false;\n this._currentTime = this._playStartPosition;\n this._stopTimeUpdateLoop();\n this._adapter?.setLoop(false, this._loopStart, this._loopEnd);\n this._adapter?.stop();\n this._emit('stop');\n this._emitStateChange();\n }\n\n seek(time: number): void {\n this._currentTime = Math.max(0, time);\n this._adapter?.seek(this._currentTime);\n this._emitStateChange();\n }\n\n setMasterVolume(volume: number): void {\n if (volume === this._masterVolume) return;\n this._masterVolume = volume;\n this._adapter?.setMasterVolume(volume);\n this._emitStateChange();\n }\n\n getCurrentTime(): number {\n if (this._isPlaying && this._adapter) {\n return this._adapter.getCurrentTime();\n }\n return this._currentTime;\n }\n\n // ---------------------------------------------------------------------------\n // Selection & Loop\n // ---------------------------------------------------------------------------\n\n setSelection(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._selectionStart && e === this._selectionEnd) return;\n this._selectionStart = s;\n this._selectionEnd = e;\n this._emitStateChange();\n }\n\n setLoopRegion(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._loopStart && e === this._loopEnd) return;\n this._loopStart = s;\n this._loopEnd = e;\n this._adapter?.setLoop(\n this._isLoopEnabled && this._isBeforeLoopEnd(),\n this._loopStart,\n this._loopEnd\n );\n this._emitStateChange();\n }\n\n setLoopEnabled(enabled: boolean): void {\n if (enabled === this._isLoopEnabled) return;\n this._isLoopEnabled = enabled;\n this._adapter?.setLoop(enabled && this._isBeforeLoopEnd(), this._loopStart, this._loopEnd);\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.volume = volume;\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.muted = muted;\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.soloed = soloed;\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.pan = pan;\n this._adapter?.setTrackPan(trackId, pan);\n }\n\n // ---------------------------------------------------------------------------\n // Zoom\n // ---------------------------------------------------------------------------\n\n zoomIn(): void {\n if (this._zoomIndex > 0) {\n this._zoomIndex--;\n this._emitStateChange();\n }\n }\n\n zoomOut(): void {\n if (this._zoomIndex < this._zoomLevels.length - 1) {\n this._zoomIndex++;\n this._emitStateChange();\n }\n }\n\n setZoomLevel(samplesPerPixel: number): void {\n const newIndex = findClosestZoomIndex(samplesPerPixel, this._zoomLevels);\n if (newIndex === this._zoomIndex) return;\n this._zoomIndex = newIndex;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Events\n // ---------------------------------------------------------------------------\n\n on<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n this._listeners.get(event)!.add(listener);\n }\n\n off<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n this._listeners.get(event)?.delete(listener);\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n dispose(): void {\n if (this._disposed) return;\n this._disposed = true;\n this._stopTimeUpdateLoop();\n try {\n this._adapter?.dispose();\n } catch (err) {\n console.warn('[waveform-playlist/engine] Error disposing adapter:', err);\n }\n this._listeners.clear();\n }\n\n // ---------------------------------------------------------------------------\n // Private helpers\n // ---------------------------------------------------------------------------\n\n private _emit(event: string, ...args: unknown[]): void {\n const listeners = this._listeners.get(event);\n if (listeners) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch (error) {\n console.warn('[waveform-playlist/engine] Error in event listener:', error);\n }\n }\n }\n }\n\n /**\n * Returns whether the current playback position is before loopEnd.\n * Used by setLoopEnabled/setLoopRegion during playback — if past loopEnd,\n * Transport loop stays off so playback continues to the end.\n * Note: play() uses an inline check instead — _isPlaying is still false\n * when play() runs, and this method returns true unconditionally when\n * not playing.\n */\n private _isBeforeLoopEnd(): boolean {\n if (!this._isPlaying) return true;\n const t = this._adapter?.getCurrentTime() ?? this._currentTime;\n return t < this._loopEnd;\n }\n\n private _emitStateChange(): void {\n this._emit('statechange', this.getState());\n }\n\n private _startTimeUpdateLoop(): void {\n // Guard for Node.js / SSR environments where RAF is unavailable\n if (typeof requestAnimationFrame === 'undefined') return;\n\n this._stopTimeUpdateLoop();\n\n const tick = () => {\n if (this._disposed || !this._isPlaying) return;\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n this._emit('timeupdate', this._currentTime);\n }\n this._animFrameId = requestAnimationFrame(tick);\n };\n\n this._animFrameId = requestAnimationFrame(tick);\n }\n\n private _stopTimeUpdateLoop(): void {\n if (this._animFrameId !== null && typeof cancelAnimationFrame !== 'undefined') {\n cancelAnimationFrame(this._animFrameId);\n this._animFrameId = null;\n }\n }\n}\n"],"mappings":";AAQA,SAAS,kBAAkB;AAYpB,SAAS,kBACd,MACA,cACA,aACA,WACQ;AACR,MAAI,QAAQ;AAGZ,QAAM,WAAW,CAAC,KAAK;AACvB,UAAQ,KAAK,IAAI,OAAO,QAAQ;AAGhC,MAAI,YAAY,GAAG;AACjB,UAAM,WAAW,YAAY,YAAY,CAAC;AAC1C,UAAM,cAAc,SAAS,cAAc,SAAS;AAEpD,UAAM,eAAe,cAAc,KAAK;AACxC,YAAQ,KAAK,IAAI,OAAO,YAAY;AAAA,EACtC;AAGA,MAAI,YAAY,YAAY,SAAS,GAAG;AACtC,UAAM,WAAW,YAAY,YAAY,CAAC;AAE1C,UAAM,eAAe,SAAS,eAAe,KAAK,cAAc,KAAK;AACrE,YAAQ,KAAK,IAAI,OAAO,YAAY;AAAA,EACtC;AAEA,SAAO;AACT;AAmBO,SAAS,sBACd,MACA,cACA,UACA,aACA,WACA,oBACQ;AACR,MAAI,QAAQ;AAEZ,MAAI,aAAa,QAAQ;AAEvB,YAAQ,KAAK,IAAI,OAAO,CAAC,KAAK,WAAW;AAGzC,YAAQ,KAAK,IAAI,OAAO,CAAC,KAAK,aAAa;AAG3C,QAAI,YAAY,GAAG;AACjB,YAAM,WAAW,YAAY,YAAY,CAAC;AAC1C,YAAM,cAAc,SAAS,cAAc,SAAS;AAEpD,cAAQ,KAAK,IAAI,OAAO,cAAc,KAAK,WAAW;AAAA,IACxD;AAIA,YAAQ,KAAK,IAAI,OAAO,KAAK,kBAAkB,kBAAkB;AAAA,EACnE,OAAO;AAKL,YAAQ,KAAK,IAAI,OAAO,qBAAqB,KAAK,eAAe;AAIjE,YAAQ,KAAK,IAAI,OAAO,KAAK,wBAAwB,KAAK,gBAAgB,KAAK,eAAe;AAG9F,QAAI,YAAY,YAAY,SAAS,GAAG;AACtC,YAAM,WAAW,YAAY,YAAY,CAAC;AAE1C,cAAQ,KAAK,IAAI,OAAO,SAAS,cAAc,KAAK,cAAc,KAAK,eAAe;AAAA,IACxF;AAAA,EACF;AAEA,SAAO;AACT;AASO,SAAS,oBAAoB,aAAqB,iBAAiC;AACxF,SAAO,KAAK,MAAM,cAAc,eAAe,IAAI;AACrD;AAaO,SAAS,UACd,MACA,aACuC;AACvC,QAAM,eAAe,cAAc,KAAK;AACxC,QAAM,gBAAgB,KAAK,kBAAkB;AAE7C,QAAM,WAAW,KAAK,OAAO,GAAG,KAAK,IAAI,SAAS;AAClD,QAAM,YAAY,KAAK,OAAO,GAAG,KAAK,IAAI,SAAS;AAEnD,QAAM,OAAO,WAAW;AAAA,IACtB,aAAa,KAAK;AAAA,IAClB,iBAAiB;AAAA,IACjB,eAAe,KAAK;AAAA,IACpB,YAAY,KAAK;AAAA,IACjB,uBAAuB,KAAK;AAAA,IAC5B,MAAM,KAAK;AAAA,IACX,MAAM;AAAA,IACN,OAAO,KAAK;AAAA,IACZ,QAAQ,KAAK;AAAA,IACb,aAAa,KAAK;AAAA,IAClB,cAAc,KAAK;AAAA,EACrB,CAAC;AAED,QAAM,QAAQ,WAAW;AAAA,IACvB,aAAa;AAAA,IACb,iBAAiB;AAAA,IACjB,eAAe,KAAK,gBAAgB;AAAA,IACpC,YAAY,KAAK;AAAA,IACjB,uBAAuB,KAAK;AAAA,IAC5B,MAAM,KAAK;AAAA,IACX,MAAM;AAAA,IACN,OAAO,KAAK;AAAA,IACZ,SAAS,KAAK;AAAA,IACd,aAAa,KAAK;AAAA,IAClB,cAAc,KAAK;AAAA,EACrB,CAAC;AAED,SAAO,EAAE,MAAM,MAAM;AACvB;AAaO,SAAS,WAAW,MAAiB,QAAgB,oBAAqC;AAC/F,QAAM,UAAU,KAAK,cAAc,KAAK;AAGxC,MAAI,UAAU,KAAK,eAAe,UAAU,SAAS;AACnD,WAAO;AAAA,EACT;AAGA,QAAM,eAAe,SAAS,KAAK;AACnC,QAAM,gBAAgB,UAAU;AAEhC,SAAO,gBAAgB,sBAAsB,iBAAiB;AAChE;;;AC5LO,SAAS,wBACd,YACA,gBACA,cAAsB,KACwB;AAC9C,QAAM,SAAS,iBAAiB;AAChC,SAAO;AAAA,IACL,cAAc,KAAK,IAAI,GAAG,aAAa,MAAM;AAAA,IAC7C,YAAY,aAAa,iBAAiB;AAAA,EAC5C;AACF;AAeO,SAAS,uBACd,YACA,YACA,cACA,YACU;AACV,QAAM,cAAc,KAAK,KAAK,aAAa,UAAU;AACrD,QAAM,UAAoB,CAAC;AAE3B,WAAS,IAAI,GAAG,IAAI,aAAa,KAAK;AACpC,UAAM,YAAY,IAAI;AACtB,UAAM,iBAAiB,KAAK,IAAI,aAAa,WAAW,UAAU;AAClE,UAAM,WAAW,YAAY;AAE7B,QAAI,YAAY,gBAAgB,aAAa,YAAY;AACvD;AAAA,IACF;AAEA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,SAAO;AACT;AAcO,SAAS,qBACd,eACA,eACA,YAAoB,KACX;AACT,SAAO,KAAK,IAAI,gBAAgB,aAAa,KAAK;AACpD;;;AC3EO,SAAS,kBAAkB,QAA6B;AAC7D,MAAI,cAAc;AAClB,aAAW,SAAS,QAAQ;AAC1B,eAAW,QAAQ,MAAM,OAAO;AAC9B,YAAM,gBAAgB,KAAK,cAAc,KAAK;AAC9C,YAAM,UAAU,gBAAgB,KAAK;AACrC,oBAAc,KAAK,IAAI,aAAa,OAAO;AAAA,IAC7C;AAAA,EACF;AACA,SAAO;AACT;AAWO,SAAS,qBAAqB,uBAA+B,YAA8B;AAChG,MAAI,WAAW,WAAW,EAAG,QAAO;AAEpC,MAAI,YAAY;AAChB,MAAI,WAAW,KAAK,IAAI,WAAW,CAAC,IAAI,qBAAqB;AAE7D,WAAS,IAAI,GAAG,IAAI,WAAW,QAAQ,KAAK;AAC1C,UAAM,OAAO,KAAK,IAAI,WAAW,CAAC,IAAI,qBAAqB;AAC3D,QAAI,OAAO,UAAU;AACnB,iBAAW;AACX,kBAAY;AAAA,IACd;AAAA,EACF;AAEA,SAAO;AACT;AAeO,SAAS,4BACd,oBACA,oBACA,YACA,gBACA,YACA,eAAuB,GACf;AACR,QAAM,cAAc,aAAa,iBAAiB,IAAI;AACtD,QAAM,aAAc,cAAc,qBAAsB;AACxD,QAAM,iBAAkB,aAAa,aAAc;AACnD,QAAM,gBAAgB,iBAAiB,eAAe,iBAAiB;AACvE,SAAO,KAAK,IAAI,GAAG,aAAa;AAClC;AASO,SAAS,kBAAkB,MAAc,UAA0B;AACxE,SAAO,KAAK,IAAI,GAAG,KAAK,IAAI,MAAM,QAAQ,CAAC;AAC7C;;;AC7EA,SAAS,uBAAuB;AAUhC,IAAM,sBAAsB;AAC5B,IAAM,4BAA4B;AAClC,IAAM,sBAAsB,CAAC,KAAK,KAAK,MAAM,MAAM,MAAM,IAAI;AAC7D,IAAM,+BAA+B;AAI9B,IAAM,iBAAN,MAAqB;AAAA,EAsB1B,YAAY,UAAiC,CAAC,GAAG;AArBjD,SAAQ,UAAuB,CAAC;AAChC,SAAQ,eAAe;AACvB,SAAQ,qBAAqB;AAC7B,SAAQ,aAAa;AACrB,SAAQ,mBAAkC;AAI1C,SAAQ,kBAAkB;AAC1B,SAAQ,gBAAgB;AACxB,SAAQ,gBAAgB;AACxB,SAAQ,aAAa;AACrB,SAAQ,WAAW;AACnB,SAAQ,iBAAiB;AACzB,SAAQ,iBAAiB;AAEzB,SAAQ,eAA8B;AACtC,SAAQ,YAAY;AAEpB;AAAA,SAAQ,aAAyC,oBAAI,IAAI;AAGvD,SAAK,cAAc,QAAQ,cAAc;AACzC,SAAK,cAAc,CAAC,GAAI,QAAQ,cAAc,mBAAoB;AAClE,SAAK,WAAW,QAAQ,WAAW;AAEnC,QAAI,KAAK,YAAY,WAAW,GAAG;AACjC,YAAM,IAAI,MAAM,8CAA8C;AAAA,IAChE;AAEA,UAAM,aAAa,QAAQ,mBAAmB;AAC9C,UAAM,YAAY,KAAK,YAAY,QAAQ,UAAU;AACrD,QAAI,cAAc,IAAI;AACpB,YAAM,IAAI;AAAA,QACR,mCAAmC,UAAU,0BAA0B,KAAK,YAAY,KAAK,IAAI,CAAC,gFACnB,UAAU;AAAA,MAC3F;AAAA,IACF;AACA,SAAK,aAAa;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA,EAMA,WAAwB;AACtB,WAAO;AAAA,MACL,QAAQ,KAAK,QAAQ,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;AAAA,MAC/D,eAAe,KAAK;AAAA,MACpB,UAAU,kBAAkB,KAAK,OAAO;AAAA,MACxC,aAAa,KAAK;AAAA,MAClB,WAAW,KAAK;AAAA,MAChB,iBAAiB,KAAK,YAAY,KAAK,UAAU;AAAA,MACjD,YAAY,KAAK;AAAA,MACjB,iBAAiB,KAAK;AAAA,MACtB,WAAW,KAAK;AAAA,MAChB,WAAW,KAAK,aAAa;AAAA,MAC7B,YAAY,KAAK,aAAa,KAAK,YAAY,SAAS;AAAA,MACxD,gBAAgB,KAAK;AAAA,MACrB,cAAc,KAAK;AAAA,MACnB,cAAc,KAAK;AAAA,MACnB,WAAW,KAAK;AAAA,MAChB,SAAS,KAAK;AAAA,MACd,eAAe,KAAK;AAAA,IACtB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,UAAU,QAA2B;AACnC,SAAK,UAAU,CAAC,GAAG,MAAM;AACzB,SAAK;AACL,SAAK,UAAU,UAAU,KAAK,OAAO;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SAAS,OAAwB;AAC/B,SAAK,UAAU,CAAC,GAAG,KAAK,SAAS,KAAK;AACtC,SAAK;AACL,QAAI,KAAK,UAAU,UAAU;AAC3B,WAAK,SAAS,SAAS,KAAK;AAAA,IAC9B,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,YAAY,SAAuB;AACjC,QAAI,CAAC,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO,EAAG;AACjD,SAAK,UAAU,KAAK,QAAQ,OAAO,CAAC,MAAM,EAAE,OAAO,OAAO;AAC1D,SAAK;AACL,QAAI,KAAK,qBAAqB,SAAS;AACrC,WAAK,mBAAmB;AAAA,IAC1B;AACA,QAAI,KAAK,UAAU,aAAa;AAC9B,WAAK,SAAS,YAAY,OAAO;AAAA,IACnC,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA,EAGA,YAAY,SAAiB,OAAyB;AACpD,UAAM,WAAW,SAAS,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACnE,QAAI,CAAC,SAAU;AACf,QAAI,OAAO;AACT,WAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAO,EAAE,OAAO,UAAU,QAAQ,CAAE;AACrE,WAAK;AAAA,IACP;AACA,QAAI,KAAK,UAAU,aAAa;AAC9B,WAAK,SAAS,YAAY,SAAS,QAAQ;AAAA,IAC7C,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AAEA,QAAI,MAAO,MAAK,iBAAiB;AAAA,EACnC;AAAA;AAAA,EAGQ,sBAAsB,SAAuB;AACnD,UAAM,IAAI,KAAK,QAAQ,KAAK,CAAC,OAAO,GAAG,OAAO,OAAO;AACrD,QAAI,CAAC,EAAG;AACR,QAAI,KAAK,UAAU,aAAa;AAC9B,WAAK,SAAS,YAAY,SAAS,CAAC;AAAA,IACtC,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AAAA,EACF;AAAA,EAEA,YAAY,SAA8B;AACxC,QAAI,YAAY,KAAK,iBAAkB;AACvC,SAAK,mBAAmB;AACxB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,cACE,SACA,QAMO;AACP,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO,QAAO;AACnB,UAAM,OAAO,MAAM,MAAM,KAAK,CAAC,MAAiB,EAAE,OAAO,MAAM;AAC/D,QAAI,CAAC,KAAM,QAAO;AAClB,WAAO;AAAA,MACL,eAAe,KAAK;AAAA,MACpB,iBAAiB,KAAK;AAAA,MACtB,aAAa,KAAK;AAAA,MAClB,uBAAuB,KAAK;AAAA,IAC9B;AAAA,EACF;AAAA;AAAA;AAAA,EAIA,mBACE,SACA,QACA,UACA,cACQ;AACR,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO,QAAO;AACnB,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,GAAI,QAAO;AAE7B,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,gBAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAC3E,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,SAAS,SAAiB,QAAgB,cAAsB,cAAc,OAAa;AACzF,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,OAAO;AACV,cAAQ,KAAK,+CAA+C,OAAO,aAAa;AAChF;AAAA,IACF;AAEA,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,IAAI;AACpB,cAAQ;AAAA,QACN,8CAA8C,MAAM,yBAAyB,OAAO;AAAA,MACtF;AACA;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,gBAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAE3E,UAAM,mBAAmB,kBAAkB,MAAM,cAAc,aAAa,WAAW;AAEvF,QAAI,qBAAqB,EAAG;AAE5B,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,EAAE,MAAM;AAAA,QAAI,CAAC,GAAc,MAC1C,MAAM,YACF;AAAA,UACE,GAAG;AAAA,UACH,aAAa,KAAK,MAAM,EAAE,cAAc,gBAAgB;AAAA,QAC1D,IACA;AAAA,MACN;AACA,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK;AACL,QAAI,CAAC,aAAa;AAChB,WAAK,sBAAsB,OAAO;AAAA,IACpC;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,UAAU,SAAiB,QAAgB,UAAwB;AACjE,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,OAAO;AACV,cAAQ,KAAK,gDAAgD,OAAO,aAAa;AACjF;AAAA,IACF;AAEA,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,IAAI;AACpB,cAAQ;AAAA,QACN,+CAA+C,MAAM,yBAAyB,OAAO;AAAA,MACvF;AACA;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,QAAI,CAAC,WAAW,MAAM,UAAU,WAAW,GAAG;AAC5C,cAAQ;AAAA,QACN,4DAA4D,MAAM,eAAe,QAAQ,iBACvE,KAAK,WAAW,SAAI,KAAK,cAAc,KAAK,eAAe,kBAAkB,WAAW;AAAA,MAC5G;AACA;AAAA,IACF;AAEA,UAAM,EAAE,MAAM,MAAM,IAAI,UAAY,MAAM,QAAQ;AAElD,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,CAAC,GAAG,EAAE,KAAK;AAC5B,eAAS,OAAO,WAAW,GAAG,MAAM,KAAK;AACzC,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK;AACL,SAAK,sBAAsB,OAAO;AAClC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SACE,SACA,QACA,UACA,cACA,cAAc,OACR;AACN,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,OAAO;AACV,cAAQ,KAAK,+CAA+C,OAAO,aAAa;AAChF;AAAA,IACF;AAEA,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,IAAI;AACpB,cAAQ;AAAA,QACN,8CAA8C,MAAM,yBAAyB,OAAO;AAAA,MACtF;AACA;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,gBAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAC3E,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,UAAM,cAAc;AAAA,MAClB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAEA,QAAI,gBAAgB,EAAG;AAEvB,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,EAAE,MAAM,IAAI,CAAC,GAAc,MAAc;AACxD,YAAI,MAAM,UAAW,QAAO;AAC5B,YAAI,aAAa,QAAQ;AACvB,iBAAO;AAAA,YACL,GAAG;AAAA,YACH,aAAa,EAAE,cAAc;AAAA,YAC7B,eAAe,EAAE,gBAAgB;AAAA,YACjC,iBAAiB,EAAE,kBAAkB;AAAA,UACvC;AAAA,QACF,OAAO;AACL,iBAAO,EAAE,GAAG,GAAG,iBAAiB,EAAE,kBAAkB,YAAY;AAAA,QAClE;AAAA,MACF,CAAC;AACD,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK;AACL,QAAI,CAAC,aAAa;AAChB,WAAK,sBAAsB,OAAO;AAAA,IACpC;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,OAAsB;AAC1B,QAAI,KAAK,UAAU;AACjB,YAAM,KAAK,SAAS,KAAK;AAAA,IAC3B;AAAA,EACF;AAAA,EAEA,KAAK,WAAoB,SAAwB;AAC/C,UAAM,kBAAkB,KAAK;AAC7B,UAAM,wBAAwB,KAAK;AAEnC,QAAI,cAAc,QAAW;AAC3B,WAAK,eAAe,KAAK,IAAI,GAAG,SAAS;AAAA,IAC3C;AAGA,SAAK,qBAAqB,KAAK;AAE/B,QAAI,KAAK,UAAU;AAKjB,UAAI,YAAY,QAAW;AAEzB,aAAK,SAAS,QAAQ,OAAO,KAAK,YAAY,KAAK,QAAQ;AAAA,MAC7D,WAAW,KAAK,gBAAgB;AAG9B,cAAM,gBAAgB,KAAK,eAAe,KAAK;AAC/C,aAAK,SAAS,QAAQ,eAAe,KAAK,YAAY,KAAK,QAAQ;AAAA,MACrE;AACA,UAAI;AACF,aAAK,SAAS,KAAK,KAAK,cAAc,OAAO;AAAA,MAC/C,SAAS,KAAK;AAGZ,aAAK,eAAe;AACpB,aAAK,qBAAqB;AAC1B,cAAM;AAAA,MACR;AAAA,IACF;AAEA,SAAK,aAAa;AAClB,SAAK,qBAAqB;AAC1B,SAAK,MAAM,MAAM;AACjB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,QAAc;AACZ,SAAK,aAAa;AAClB,SAAK,oBAAoB;AACzB,SAAK,UAAU,MAAM;AACrB,QAAI,KAAK,UAAU;AACjB,WAAK,eAAe,KAAK,SAAS,eAAe;AAAA,IACnD;AACA,SAAK,MAAM,OAAO;AAClB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,OAAa;AACX,SAAK,aAAa;AAClB,SAAK,eAAe,KAAK;AACzB,SAAK,oBAAoB;AACzB,SAAK,UAAU,QAAQ,OAAO,KAAK,YAAY,KAAK,QAAQ;AAC5D,SAAK,UAAU,KAAK;AACpB,SAAK,MAAM,MAAM;AACjB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,KAAK,MAAoB;AACvB,SAAK,eAAe,KAAK,IAAI,GAAG,IAAI;AACpC,SAAK,UAAU,KAAK,KAAK,YAAY;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,gBAAgB,QAAsB;AACpC,QAAI,WAAW,KAAK,cAAe;AACnC,SAAK,gBAAgB;AACrB,SAAK,UAAU,gBAAgB,MAAM;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,iBAAyB;AACvB,QAAI,KAAK,cAAc,KAAK,UAAU;AACpC,aAAO,KAAK,SAAS,eAAe;AAAA,IACtC;AACA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAMA,aAAa,OAAe,KAAmB;AAC7C,UAAM,IAAI,KAAK,IAAI,OAAO,GAAG;AAC7B,UAAM,IAAI,KAAK,IAAI,OAAO,GAAG;AAC7B,QAAI,MAAM,KAAK,mBAAmB,MAAM,KAAK,cAAe;AAC5D,SAAK,kBAAkB;AACvB,SAAK,gBAAgB;AACrB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,cAAc,OAAe,KAAmB;AAC9C,UAAM,IAAI,KAAK,IAAI,OAAO,GAAG;AAC7B,UAAM,IAAI,KAAK,IAAI,OAAO,GAAG;AAC7B,QAAI,MAAM,KAAK,cAAc,MAAM,KAAK,SAAU;AAClD,SAAK,aAAa;AAClB,SAAK,WAAW;AAChB,SAAK,UAAU;AAAA,MACb,KAAK,kBAAkB,KAAK,iBAAiB;AAAA,MAC7C,KAAK;AAAA,MACL,KAAK;AAAA,IACP;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,eAAe,SAAwB;AACrC,QAAI,YAAY,KAAK,eAAgB;AACrC,SAAK,iBAAiB;AACtB,SAAK,UAAU,QAAQ,WAAW,KAAK,iBAAiB,GAAG,KAAK,YAAY,KAAK,QAAQ;AACzF,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,eAAe,SAAiB,QAAsB;AACpD,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,MAAO,OAAM,SAAS;AAC1B,SAAK,UAAU,eAAe,SAAS,MAAM;AAAA,EAC/C;AAAA,EAEA,aAAa,SAAiB,OAAsB;AAClD,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,MAAO,OAAM,QAAQ;AACzB,SAAK,UAAU,aAAa,SAAS,KAAK;AAAA,EAC5C;AAAA,EAEA,aAAa,SAAiB,QAAuB;AACnD,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,MAAO,OAAM,SAAS;AAC1B,SAAK,UAAU,aAAa,SAAS,MAAM;AAAA,EAC7C;AAAA,EAEA,YAAY,SAAiB,KAAmB;AAC9C,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,MAAO,OAAM,MAAM;AACvB,SAAK,UAAU,YAAY,SAAS,GAAG;AAAA,EACzC;AAAA;AAAA;AAAA;AAAA,EAMA,SAAe;AACb,QAAI,KAAK,aAAa,GAAG;AACvB,WAAK;AACL,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,QAAI,KAAK,aAAa,KAAK,YAAY,SAAS,GAAG;AACjD,WAAK;AACL,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,aAAa,iBAA+B;AAC1C,UAAM,WAAW,qBAAqB,iBAAiB,KAAK,WAAW;AACvE,QAAI,aAAa,KAAK,WAAY;AAClC,SAAK,aAAa;AAClB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,GAAwB,OAAU,UAAiC;AACjE,QAAI,CAAC,KAAK,WAAW,IAAI,KAAK,GAAG;AAC/B,WAAK,WAAW,IAAI,OAAO,oBAAI,IAAI,CAAC;AAAA,IACtC;AACA,SAAK,WAAW,IAAI,KAAK,EAAG,IAAI,QAAQ;AAAA,EAC1C;AAAA,EAEA,IAAyB,OAAU,UAAiC;AAClE,SAAK,WAAW,IAAI,KAAK,GAAG,OAAO,QAAQ;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA,EAMA,UAAgB;AACd,QAAI,KAAK,UAAW;AACpB,SAAK,YAAY;AACjB,SAAK,oBAAoB;AACzB,QAAI;AACF,WAAK,UAAU,QAAQ;AAAA,IACzB,SAAS,KAAK;AACZ,cAAQ,KAAK,uDAAuD,GAAG;AAAA,IACzE;AACA,SAAK,WAAW,MAAM;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMQ,MAAM,UAAkB,MAAuB;AACrD,UAAM,YAAY,KAAK,WAAW,IAAI,KAAK;AAC3C,QAAI,WAAW;AACb,iBAAW,YAAY,WAAW;AAChC,YAAI;AACF,mBAAS,GAAG,IAAI;AAAA,QAClB,SAAS,OAAO;AACd,kBAAQ,KAAK,uDAAuD,KAAK;AAAA,QAC3E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUQ,mBAA4B;AAClC,QAAI,CAAC,KAAK,WAAY,QAAO;AAC7B,UAAM,IAAI,KAAK,UAAU,eAAe,KAAK,KAAK;AAClD,WAAO,IAAI,KAAK;AAAA,EAClB;AAAA,EAEQ,mBAAyB;AAC/B,SAAK,MAAM,eAAe,KAAK,SAAS,CAAC;AAAA,EAC3C;AAAA,EAEQ,uBAA6B;AAEnC,QAAI,OAAO,0BAA0B,YAAa;AAElD,SAAK,oBAAoB;AAEzB,UAAM,OAAO,MAAM;AACjB,UAAI,KAAK,aAAa,CAAC,KAAK,WAAY;AACxC,UAAI,KAAK,UAAU;AACjB,aAAK,eAAe,KAAK,SAAS,eAAe;AACjD,aAAK,MAAM,cAAc,KAAK,YAAY;AAAA,MAC5C;AACA,WAAK,eAAe,sBAAsB,IAAI;AAAA,IAChD;AAEA,SAAK,eAAe,sBAAsB,IAAI;AAAA,EAChD;AAAA,EAEQ,sBAA4B;AAClC,QAAI,KAAK,iBAAiB,QAAQ,OAAO,yBAAyB,aAAa;AAC7E,2BAAqB,KAAK,YAAY;AACtC,WAAK,eAAe;AAAA,IACtB;AAAA,EACF;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/operations/clipOperations.ts","../src/operations/viewportOperations.ts","../src/operations/timelineOperations.ts","../src/PlaylistEngine.ts"],"sourcesContent":["/**\n * Clip Operations\n *\n * Pure functions for constraining clip movement, boundary trimming,\n * and splitting clips on a timeline. All positions are in samples (integers).\n */\n\nimport type { AudioClip } from '@waveform-playlist/core';\nimport { createClip } from '@waveform-playlist/core';\n\n/**\n * Constrain clip movement delta to prevent overlaps with adjacent clips\n * and going before sample 0.\n *\n * @param clip - The clip being dragged\n * @param deltaSamples - Requested movement in samples (negative = left, positive = right)\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the dragged clip in sortedClips\n * @returns Constrained delta that prevents overlaps\n */\nexport function constrainClipDrag(\n clip: AudioClip,\n deltaSamples: number,\n sortedClips: AudioClip[],\n clipIndex: number\n): number {\n let delta = deltaSamples;\n\n // Constraint 1: Cannot go before sample 0\n const minDelta = -clip.startSample;\n delta = Math.max(delta, minDelta);\n\n // Constraint 2: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // clip.startSample + delta >= prevClipEnd\n const minDeltaPrev = prevClipEnd - clip.startSample;\n delta = Math.max(delta, minDeltaPrev);\n }\n\n // Constraint 3: Cannot overlap next clip\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // clip.startSample + clip.durationSamples + delta <= nextClip.startSample\n const maxDeltaNext = nextClip.startSample - (clip.startSample + clip.durationSamples);\n delta = Math.min(delta, maxDeltaNext);\n }\n\n return delta;\n}\n\n/**\n * Constrain boundary trim delta for left or right edge of a clip.\n *\n * LEFT boundary: delta moves the left edge (positive = shrink, negative = expand)\n * - startSample += delta, offsetSamples += delta, durationSamples -= delta\n *\n * RIGHT boundary: delta applied to durationSamples (positive = expand, negative = shrink)\n * - durationSamples += delta\n *\n * @param clip - The clip being trimmed\n * @param deltaSamples - Requested trim delta in samples\n * @param boundary - Which edge is being trimmed: 'left' or 'right'\n * @param sortedClips - All clips on the track, sorted by startSample\n * @param clipIndex - Index of the trimmed clip in sortedClips\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns Constrained delta\n */\nexport function constrainBoundaryTrim(\n clip: AudioClip,\n deltaSamples: number,\n boundary: 'left' | 'right',\n sortedClips: AudioClip[],\n clipIndex: number,\n minDurationSamples: number\n): number {\n let delta = deltaSamples;\n\n if (boundary === 'left') {\n // Constraint 1: startSample + delta >= 0\n delta = Math.max(delta, -clip.startSample);\n\n // Constraint 2: offsetSamples + delta >= 0\n delta = Math.max(delta, -clip.offsetSamples);\n\n // Constraint 3: Cannot overlap previous clip\n if (clipIndex > 0) {\n const prevClip = sortedClips[clipIndex - 1];\n const prevClipEnd = prevClip.startSample + prevClip.durationSamples;\n // startSample + delta >= prevClipEnd\n delta = Math.max(delta, prevClipEnd - clip.startSample);\n }\n\n // Constraint 4: durationSamples - delta >= minDurationSamples\n // delta <= durationSamples - minDurationSamples\n delta = Math.min(delta, clip.durationSamples - minDurationSamples);\n } else {\n // RIGHT boundary\n\n // Constraint 1: durationSamples + delta >= minDurationSamples\n // delta >= minDurationSamples - durationSamples\n delta = Math.max(delta, minDurationSamples - clip.durationSamples);\n\n // Constraint 2: offsetSamples + (durationSamples + delta) <= sourceDurationSamples\n // delta <= sourceDurationSamples - offsetSamples - durationSamples\n delta = Math.min(delta, clip.sourceDurationSamples - clip.offsetSamples - clip.durationSamples);\n\n // Constraint 3: startSample + (durationSamples + delta) <= nextClip.startSample\n if (clipIndex < sortedClips.length - 1) {\n const nextClip = sortedClips[clipIndex + 1];\n // delta <= nextClip.startSample - startSample - durationSamples\n delta = Math.min(delta, nextClip.startSample - clip.startSample - clip.durationSamples);\n }\n }\n\n return delta;\n}\n\n/**\n * Snap a split sample position to the nearest pixel boundary.\n *\n * @param splitSample - The sample position to snap\n * @param samplesPerPixel - Current zoom level (samples per pixel)\n * @returns Snapped sample position\n */\nexport function calculateSplitPoint(splitSample: number, samplesPerPixel: number): number {\n return Math.floor(splitSample / samplesPerPixel) * samplesPerPixel;\n}\n\n/**\n * Split a clip into two clips at the given sample position.\n *\n * The left clip retains the original fadeIn; the right clip retains the original fadeOut.\n * Both clips share the same waveformData reference.\n * If the clip has a name, suffixes \" (1)\" and \" (2)\" are appended.\n *\n * @param clip - The clip to split\n * @param splitSample - The timeline sample position where the split occurs\n * @returns Object with `left` and `right` AudioClip\n */\nexport function splitClip(\n clip: AudioClip,\n splitSample: number\n): { left: AudioClip; right: AudioClip } {\n const leftDuration = splitSample - clip.startSample;\n const rightDuration = clip.durationSamples - leftDuration;\n\n const leftName = clip.name ? `${clip.name} (1)` : undefined;\n const rightName = clip.name ? `${clip.name} (2)` : undefined;\n\n const left = createClip({\n startSample: clip.startSample,\n durationSamples: leftDuration,\n offsetSamples: clip.offsetSamples,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: leftName,\n color: clip.color,\n fadeIn: clip.fadeIn,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n const right = createClip({\n startSample: splitSample,\n durationSamples: rightDuration,\n offsetSamples: clip.offsetSamples + leftDuration,\n sampleRate: clip.sampleRate,\n sourceDurationSamples: clip.sourceDurationSamples,\n gain: clip.gain,\n name: rightName,\n color: clip.color,\n fadeOut: clip.fadeOut,\n audioBuffer: clip.audioBuffer,\n waveformData: clip.waveformData,\n });\n\n return { left, right };\n}\n\n/**\n * Check whether a clip can be split at the given sample position.\n *\n * The split point must be strictly inside the clip (not at start or end),\n * and both resulting clips must meet the minimum duration requirement.\n *\n * @param clip - The clip to check\n * @param sample - The timeline sample position to test\n * @param minDurationSamples - Minimum allowed clip duration in samples\n * @returns true if the split is valid\n */\nexport function canSplitAt(clip: AudioClip, sample: number, minDurationSamples: number): boolean {\n const clipEnd = clip.startSample + clip.durationSamples;\n\n // Must be strictly within clip bounds\n if (sample <= clip.startSample || sample >= clipEnd) {\n return false;\n }\n\n // Both resulting clips must meet minimum duration\n const leftDuration = sample - clip.startSample;\n const rightDuration = clipEnd - sample;\n\n return leftDuration >= minDurationSamples && rightDuration >= minDurationSamples;\n}\n","/**\n * Viewport operations for virtual scrolling.\n *\n * Pure math helpers that determine which portion of the timeline\n * is visible and which canvas chunks need to be mounted.\n */\n\n/**\n * Calculate the visible region with an overscan buffer for virtual scrolling.\n *\n * The buffer extends the visible range on both sides so that chunks are\n * mounted slightly before they scroll into view, preventing flicker.\n *\n * @param scrollLeft - Current horizontal scroll position in pixels\n * @param containerWidth - Width of the scroll container in pixels\n * @param bufferRatio - Multiplier for buffer size (default 1.5x container width)\n * @returns Object with visibleStart and visibleEnd in pixels\n */\nexport function calculateViewportBounds(\n scrollLeft: number,\n containerWidth: number,\n bufferRatio: number = 1.5\n): { visibleStart: number; visibleEnd: number } {\n const buffer = containerWidth * bufferRatio;\n return {\n visibleStart: Math.max(0, scrollLeft - buffer),\n visibleEnd: scrollLeft + containerWidth + buffer,\n };\n}\n\n/**\n * Get an array of chunk indices that overlap the visible viewport.\n *\n * Chunks are fixed-width segments of the total timeline width. Only chunks\n * that intersect [visibleStart, visibleEnd) are included. The last chunk\n * may be narrower than chunkWidth if totalWidth is not evenly divisible.\n *\n * @param totalWidth - Total width of the timeline in pixels\n * @param chunkWidth - Width of each chunk in pixels\n * @param visibleStart - Left edge of the visible region in pixels\n * @param visibleEnd - Right edge of the visible region in pixels\n * @returns Array of chunk indices (0-based) that are visible\n */\nexport function getVisibleChunkIndices(\n totalWidth: number,\n chunkWidth: number,\n visibleStart: number,\n visibleEnd: number\n): number[] {\n const totalChunks = Math.ceil(totalWidth / chunkWidth);\n const indices: number[] = [];\n\n for (let i = 0; i < totalChunks; i++) {\n const chunkLeft = i * chunkWidth;\n const thisChunkWidth = Math.min(totalWidth - chunkLeft, chunkWidth);\n const chunkEnd = chunkLeft + thisChunkWidth;\n\n if (chunkEnd <= visibleStart || chunkLeft >= visibleEnd) {\n continue;\n }\n\n indices.push(i);\n }\n\n return indices;\n}\n\n/**\n * Determine whether a scroll change is large enough to warrant\n * recalculating the viewport and re-rendering chunks.\n *\n * Small scroll movements are ignored to avoid excessive recomputation\n * during smooth scrolling.\n *\n * @param oldScrollLeft - Previous scroll position in pixels\n * @param newScrollLeft - Current scroll position in pixels\n * @param threshold - Minimum pixel delta to trigger an update (default 100)\n * @returns true if the scroll delta meets or exceeds the threshold\n */\nexport function shouldUpdateViewport(\n oldScrollLeft: number,\n newScrollLeft: number,\n threshold: number = 100\n): boolean {\n return Math.abs(oldScrollLeft - newScrollLeft) >= threshold;\n}\n","import type { ClipTrack } from '@waveform-playlist/core';\n\n/**\n * Calculate total timeline duration in seconds from all tracks/clips.\n * Iterates all clips, finds the furthest clip end (startSample + durationSamples),\n * converts to seconds using each clip's sampleRate.\n *\n * @param tracks - Array of clip tracks\n * @returns Duration in seconds\n */\nexport function calculateDuration(tracks: ClipTrack[]): number {\n let maxDuration = 0;\n for (const track of tracks) {\n for (const clip of track.clips) {\n const clipEndSample = clip.startSample + clip.durationSamples;\n const clipEnd = clipEndSample / clip.sampleRate;\n maxDuration = Math.max(maxDuration, clipEnd);\n }\n }\n return maxDuration;\n}\n\n/**\n * Find the zoom level index closest to a given samplesPerPixel.\n * Returns exact match if found, otherwise the index whose value is\n * nearest to the target (by absolute difference).\n *\n * @param targetSamplesPerPixel - The samplesPerPixel value to find\n * @param zoomLevels - Array of available zoom levels (samplesPerPixel values)\n * @returns Index into the zoomLevels array\n */\nexport function findClosestZoomIndex(targetSamplesPerPixel: number, zoomLevels: number[]): number {\n if (zoomLevels.length === 0) return 0;\n\n let bestIndex = 0;\n let bestDiff = Math.abs(zoomLevels[0] - targetSamplesPerPixel);\n\n for (let i = 1; i < zoomLevels.length; i++) {\n const diff = Math.abs(zoomLevels[i] - targetSamplesPerPixel);\n if (diff < bestDiff) {\n bestDiff = diff;\n bestIndex = i;\n }\n }\n\n return bestIndex;\n}\n\n/**\n * Keep viewport centered during zoom changes.\n * Calculates center time from old zoom, computes new pixel position at new zoom,\n * and returns new scrollLeft clamped to >= 0.\n *\n * @param oldSamplesPerPixel - Previous zoom level\n * @param newSamplesPerPixel - New zoom level\n * @param scrollLeft - Current horizontal scroll position\n * @param containerWidth - Viewport width in pixels\n * @param sampleRate - Audio sample rate\n * @param controlWidth - Width of track controls panel (defaults to 0)\n * @returns New scrollLeft value\n */\nexport function calculateZoomScrollPosition(\n oldSamplesPerPixel: number,\n newSamplesPerPixel: number,\n scrollLeft: number,\n containerWidth: number,\n sampleRate: number,\n controlWidth: number = 0\n): number {\n const centerPixel = scrollLeft + containerWidth / 2 - controlWidth;\n const centerTime = (centerPixel * oldSamplesPerPixel) / sampleRate;\n const newCenterPixel = (centerTime * sampleRate) / newSamplesPerPixel;\n const newScrollLeft = newCenterPixel + controlWidth - containerWidth / 2;\n return Math.max(0, newScrollLeft);\n}\n\n/**\n * Clamp a seek position to the valid range [0, duration].\n *\n * @param time - Requested seek time in seconds\n * @param duration - Maximum duration in seconds\n * @returns Clamped time value\n */\nexport function clampSeekPosition(time: number, duration: number): number {\n return Math.max(0, Math.min(time, duration));\n}\n","/**\n * PlaylistEngine — Stateful, framework-agnostic timeline engine.\n *\n * Composes pure operations from ./operations with an event emitter\n * and optional PlayoutAdapter for audio playback delegation.\n */\n\nimport type { AudioClip, ClipTrack } from '@waveform-playlist/core';\nimport { sortClipsByTime } from '@waveform-playlist/core';\nimport {\n constrainClipDrag,\n constrainBoundaryTrim,\n canSplitAt,\n splitClip as splitClipOp,\n} from './operations/clipOperations';\nimport { calculateDuration, findClosestZoomIndex } from './operations/timelineOperations';\nimport type { PlayoutAdapter, EngineState, EngineEvents, PlaylistEngineOptions } from './types';\n\nconst DEFAULT_SAMPLE_RATE = 48000;\nconst DEFAULT_SAMPLES_PER_PIXEL = 1024;\nconst DEFAULT_ZOOM_LEVELS = [256, 512, 1024, 2048, 4096, 8192];\nconst DEFAULT_MIN_DURATION_SECONDS = 0.1;\n\ntype EventName = keyof EngineEvents;\n\nexport class PlaylistEngine {\n private _tracks: ClipTrack[] = [];\n private _currentTime = 0;\n private _playStartPosition = 0;\n private _isPlaying = false;\n private _selectedTrackId: string | null = null;\n private _sampleRate: number;\n private _zoomLevels: number[];\n private _zoomIndex: number;\n private _selectionStart = 0;\n private _selectionEnd = 0;\n private _masterVolume = 1.0;\n private _loopStart = 0;\n private _loopEnd = 0;\n private _isLoopEnabled = false;\n private _tracksVersion = 0;\n private _adapter: PlayoutAdapter | null;\n private _animFrameId: number | null = null;\n private _disposed = false;\n // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type\n private _listeners: Map<string, Set<Function>> = new Map();\n\n private _undoStack: ClipTrack[][] = [];\n private _redoStack: ClipTrack[][] = [];\n private _inTransaction = false;\n private _transactionSnapshot: ClipTrack[] | null = null;\n private _transactionMutated = false;\n readonly undoLimit: number;\n\n constructor(options: PlaylistEngineOptions = {}) {\n this._sampleRate = options.sampleRate ?? DEFAULT_SAMPLE_RATE;\n this._zoomLevels = [...(options.zoomLevels ?? DEFAULT_ZOOM_LEVELS)];\n this._adapter = options.adapter ?? null;\n this.undoLimit = options.undoLimit ?? 100;\n\n if (this._zoomLevels.length === 0) {\n throw new Error('PlaylistEngine: zoomLevels must not be empty');\n }\n\n const initialSpp = options.samplesPerPixel ?? DEFAULT_SAMPLES_PER_PIXEL;\n const zoomIndex = this._zoomLevels.indexOf(initialSpp);\n if (zoomIndex === -1) {\n throw new Error(\n `PlaylistEngine: samplesPerPixel ${initialSpp} is not in zoomLevels [${this._zoomLevels.join(', ')}]. ` +\n `Either pass a samplesPerPixel value that exists in zoomLevels, or include ${initialSpp} in your zoomLevels array.`\n );\n }\n this._zoomIndex = zoomIndex;\n }\n\n // ---------------------------------------------------------------------------\n // Undo/Redo\n // ---------------------------------------------------------------------------\n\n get canUndo(): boolean {\n return this._undoStack.length > 0;\n }\n\n get canRedo(): boolean {\n return this._redoStack.length > 0;\n }\n\n undo(): void {\n if (this._undoStack.length === 0) return;\n const snapshot = this._undoStack.pop()!;\n this._redoStack.push(this._snapshotTracks());\n this._restoreTracks(snapshot);\n }\n\n redo(): void {\n if (this._redoStack.length === 0) return;\n const snapshot = this._redoStack.pop()!;\n this._undoStack.push(this._snapshotTracks());\n this._restoreTracks(snapshot);\n }\n\n clearHistory(): void {\n this._undoStack = [];\n this._redoStack = [];\n }\n\n beginTransaction(): void {\n if (this._inTransaction) {\n console.warn(\n '[waveform-playlist/engine] beginTransaction: already in a transaction, ' +\n 'previous snapshot will be overwritten'\n );\n }\n this._transactionSnapshot = this._snapshotTracks();\n this._inTransaction = true;\n this._transactionMutated = false;\n }\n\n commitTransaction(): void {\n if (!this._inTransaction || this._transactionSnapshot === null) {\n console.warn('[waveform-playlist/engine] commitTransaction: no active transaction to commit');\n return;\n }\n // Only push undo entry if mutations actually occurred during the transaction.\n // Without this, no-op drags (e.g., against a boundary) create phantom undo entries.\n if (this._transactionMutated) {\n this._undoStack.push(this._transactionSnapshot);\n if (this._undoStack.length > this.undoLimit) {\n this._undoStack.shift();\n }\n this._redoStack = [];\n }\n this._transactionSnapshot = null;\n this._inTransaction = false;\n this._transactionMutated = false;\n }\n\n abortTransaction(): void {\n if (!this._inTransaction || this._transactionSnapshot === null) {\n console.warn('[waveform-playlist/engine] abortTransaction: no active transaction to abort');\n return;\n }\n const snapshot = this._transactionSnapshot;\n const mutated = this._transactionMutated;\n this._transactionSnapshot = null;\n this._inTransaction = false;\n this._transactionMutated = false;\n // Only restore if mutations occurred — avoids full adapter rebuild on click\n if (mutated) {\n this._restoreTracks(snapshot);\n }\n }\n\n // ---------------------------------------------------------------------------\n // State snapshot\n // ---------------------------------------------------------------------------\n\n getState(): EngineState {\n return {\n tracks: this._tracks.map((t) => ({ ...t, clips: [...t.clips] })),\n tracksVersion: this._tracksVersion,\n duration: calculateDuration(this._tracks),\n currentTime: this._currentTime,\n isPlaying: this._isPlaying,\n samplesPerPixel: this._zoomLevels[this._zoomIndex],\n sampleRate: this._sampleRate,\n selectedTrackId: this._selectedTrackId,\n zoomIndex: this._zoomIndex,\n canZoomIn: this._zoomIndex > 0,\n canZoomOut: this._zoomIndex < this._zoomLevels.length - 1,\n selectionStart: this._selectionStart,\n selectionEnd: this._selectionEnd,\n masterVolume: this._masterVolume,\n loopStart: this._loopStart,\n loopEnd: this._loopEnd,\n isLoopEnabled: this._isLoopEnabled,\n canUndo: this.canUndo,\n canRedo: this.canRedo,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Track Management\n // ---------------------------------------------------------------------------\n\n setTracks(tracks: ClipTrack[]): void {\n this.clearHistory();\n this._tracks = [...tracks];\n this._tracksVersion++;\n this._adapter?.setTracks(this._tracks);\n this._emitStateChange();\n }\n\n addTrack(track: ClipTrack): void {\n this._pushUndoSnapshot();\n this._tracks = [...this._tracks, track];\n this._tracksVersion++;\n if (this._adapter?.addTrack) {\n this._adapter.addTrack(track);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n removeTrack(trackId: string): void {\n if (!this._tracks.some((t) => t.id === trackId)) return;\n this._pushUndoSnapshot();\n this._tracks = this._tracks.filter((t) => t.id !== trackId);\n this._tracksVersion++;\n if (this._selectedTrackId === trackId) {\n this._selectedTrackId = null;\n }\n if (this._adapter?.removeTrack) {\n this._adapter.removeTrack(trackId);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n /** Update a single track's clips on the adapter (no full rebuild). */\n updateTrack(trackId: string, track?: ClipTrack): void {\n const resolved = track ?? this._tracks.find((t) => t.id === trackId);\n if (!resolved) return;\n if (track) {\n this._pushUndoSnapshot();\n this._tracks = this._tracks.map((t) => (t.id === trackId ? track : t));\n this._tracksVersion++;\n }\n if (this._adapter?.updateTrack) {\n this._adapter.updateTrack(trackId, resolved);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n // Only emit statechange when internal state actually changed\n if (track) this._emitStateChange();\n }\n\n /** Internal: update adapter after modifying this._tracks in place. */\n private _updateTrackOnAdapter(trackId: string): void {\n const t = this._tracks.find((tr) => tr.id === trackId);\n if (!t) return;\n if (this._adapter?.updateTrack) {\n this._adapter.updateTrack(trackId, t);\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n }\n\n selectTrack(trackId: string | null): void {\n if (trackId === this._selectedTrackId) return;\n this._selectedTrackId = trackId;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Clip Queries\n // ---------------------------------------------------------------------------\n\n /** Get a clip's full bounds for trim constraint computation. Returns null if not found. */\n getClipBounds(\n trackId: string,\n clipId: string\n ): {\n offsetSamples: number;\n durationSamples: number;\n startSample: number;\n sourceDurationSamples: number;\n } | null {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return null;\n const clip = track.clips.find((c: AudioClip) => c.id === clipId);\n if (!clip) return null;\n return {\n offsetSamples: clip.offsetSamples,\n durationSamples: clip.durationSamples,\n startSample: clip.startSample,\n sourceDurationSamples: clip.sourceDurationSamples,\n };\n }\n\n /** Constrain a trim delta using the engine's collision/bounds logic.\n * Uses the clip's current state and neighboring clips for constraints. */\n constrainTrimDelta(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number\n ): number {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) return 0;\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) return 0;\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n return constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n }\n\n // ---------------------------------------------------------------------------\n // Clip Editing (delegates to operations/)\n // ---------------------------------------------------------------------------\n\n /** Move a clip by deltaSamples. Returns the constrained delta actually applied (0 if no-op). */\n moveClip(trackId: string, clipId: string, deltaSamples: number, skipAdapter = false): number {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] moveClip: track \"${trackId}\" not found`);\n return 0;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] moveClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return 0;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n\n const constrainedDelta = constrainClipDrag(clip, deltaSamples, sortedClips, sortedIndex);\n\n if (constrainedDelta === 0) return 0;\n\n this._pushUndoSnapshot();\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) =>\n i === clipIndex\n ? {\n ...c,\n startSample: Math.floor(c.startSample + constrainedDelta),\n }\n : c\n );\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n if (!skipAdapter) {\n this._updateTrackOnAdapter(trackId);\n }\n this._emitStateChange();\n return constrainedDelta;\n }\n\n splitClip(trackId: string, clipId: string, atSample: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] splitClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] splitClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n if (!canSplitAt(clip, atSample, minDuration)) {\n console.warn(\n `[waveform-playlist/engine] splitClip: cannot split clip \"${clipId}\" at sample ${atSample} ` +\n `(clip range: ${clip.startSample}–${clip.startSample + clip.durationSamples}, minDuration: ${minDuration})`\n );\n return;\n }\n\n this._pushUndoSnapshot();\n\n const { left, right } = splitClipOp(clip, atSample);\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = [...t.clips];\n newClips.splice(clipIndex, 1, left, right);\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n this._updateTrackOnAdapter(trackId);\n this._emitStateChange();\n }\n\n trimClip(\n trackId: string,\n clipId: string,\n boundary: 'left' | 'right',\n deltaSamples: number,\n skipAdapter = false\n ): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (!track) {\n console.warn(`[waveform-playlist/engine] trimClip: track \"${trackId}\" not found`);\n return;\n }\n\n const clipIndex = track.clips.findIndex((c: AudioClip) => c.id === clipId);\n if (clipIndex === -1) {\n console.warn(\n `[waveform-playlist/engine] trimClip: clip \"${clipId}\" not found in track \"${trackId}\"`\n );\n return;\n }\n\n const clip = track.clips[clipIndex];\n const sortedClips = sortClipsByTime(track.clips);\n const sortedIndex = sortedClips.findIndex((c: AudioClip) => c.id === clipId);\n const minDuration = Math.floor(DEFAULT_MIN_DURATION_SECONDS * this._sampleRate);\n\n const constrained = constrainBoundaryTrim(\n clip,\n deltaSamples,\n boundary,\n sortedClips,\n sortedIndex,\n minDuration\n );\n\n if (constrained === 0) return;\n\n this._pushUndoSnapshot();\n\n this._tracks = this._tracks.map((t) => {\n if (t.id !== trackId) return t;\n const newClips = t.clips.map((c: AudioClip, i: number) => {\n if (i !== clipIndex) return c;\n if (boundary === 'left') {\n return {\n ...c,\n startSample: c.startSample + constrained,\n offsetSamples: c.offsetSamples + constrained,\n durationSamples: c.durationSamples - constrained,\n };\n } else {\n return { ...c, durationSamples: c.durationSamples + constrained };\n }\n });\n return { ...t, clips: newClips };\n });\n\n this._tracksVersion++;\n if (!skipAdapter) {\n this._updateTrackOnAdapter(trackId);\n }\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Playback (delegates to adapter, no-ops without adapter)\n // ---------------------------------------------------------------------------\n\n async init(): Promise<void> {\n if (this._adapter) {\n await this._adapter.init();\n }\n }\n\n play(startTime?: number, endTime?: number): void {\n const prevCurrentTime = this._currentTime;\n const prevPlayStartPosition = this._playStartPosition;\n\n if (startTime !== undefined) {\n this._currentTime = Math.max(0, startTime);\n }\n\n // Remember where playback started (Audacity-style: stop returns here)\n this._playStartPosition = this._currentTime;\n\n if (this._adapter) {\n // Configure loop state BEFORE play(). The adapter caches loopStart/\n // loopEnd/enabled from setLoop(), then TonePlayout.play() applies\n // them to the Transport before transport.start() and advances\n // Clock._lastUpdate to skip stale ghost ticks.\n if (endTime !== undefined) {\n // Disable Transport loop for duration-limited playback (selection/annotation)\n this._adapter.setLoop(false, this._loopStart, this._loopEnd);\n } else if (this._isLoopEnabled) {\n // Activate Transport loop if starting before loopEnd. Starting at or\n // past loopEnd plays to the end without looping (click-past-loop behavior).\n const beforeLoopEnd = this._currentTime < this._loopEnd;\n this._adapter.setLoop(beforeLoopEnd, this._loopStart, this._loopEnd);\n }\n try {\n this._adapter.play(this._currentTime, endTime);\n } catch (err) {\n // Restore state so the engine isn't left with a moved playhead\n // but no audio. The throw propagates to the caller.\n this._currentTime = prevCurrentTime;\n this._playStartPosition = prevPlayStartPosition;\n throw err;\n }\n }\n\n this._isPlaying = true;\n this._startTimeUpdateLoop();\n this._emit('play');\n this._emitStateChange();\n }\n\n pause(): void {\n this._isPlaying = false;\n this._stopTimeUpdateLoop();\n this._adapter?.pause();\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n }\n this._emit('pause');\n this._emitStateChange();\n }\n\n stop(): void {\n this._isPlaying = false;\n this._currentTime = this._playStartPosition;\n this._stopTimeUpdateLoop();\n this._adapter?.setLoop(false, this._loopStart, this._loopEnd);\n this._adapter?.stop();\n this._emit('stop');\n this._emitStateChange();\n }\n\n seek(time: number): void {\n this._currentTime = Math.max(0, time);\n this._adapter?.seek(this._currentTime);\n this._emitStateChange();\n }\n\n setMasterVolume(volume: number): void {\n if (volume === this._masterVolume) return;\n this._masterVolume = volume;\n this._adapter?.setMasterVolume(volume);\n this._emitStateChange();\n }\n\n getCurrentTime(): number {\n if (this._isPlaying && this._adapter) {\n return this._adapter.getCurrentTime();\n }\n return this._currentTime;\n }\n\n // ---------------------------------------------------------------------------\n // Selection & Loop\n // ---------------------------------------------------------------------------\n\n setSelection(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._selectionStart && e === this._selectionEnd) return;\n this._selectionStart = s;\n this._selectionEnd = e;\n this._emitStateChange();\n }\n\n setLoopRegion(start: number, end: number): void {\n const s = Math.min(start, end);\n const e = Math.max(start, end);\n if (s === this._loopStart && e === this._loopEnd) return;\n this._loopStart = s;\n this._loopEnd = e;\n this._adapter?.setLoop(\n this._isLoopEnabled && this._isBeforeLoopEnd(),\n this._loopStart,\n this._loopEnd\n );\n this._emitStateChange();\n }\n\n setLoopEnabled(enabled: boolean): void {\n if (enabled === this._isLoopEnabled) return;\n this._isLoopEnabled = enabled;\n this._adapter?.setLoop(enabled && this._isBeforeLoopEnd(), this._loopStart, this._loopEnd);\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Per-Track Audio (delegates to adapter)\n // ---------------------------------------------------------------------------\n\n setTrackVolume(trackId: string, volume: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.volume = volume;\n this._adapter?.setTrackVolume(trackId, volume);\n }\n\n setTrackMute(trackId: string, muted: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.muted = muted;\n this._adapter?.setTrackMute(trackId, muted);\n }\n\n setTrackSolo(trackId: string, soloed: boolean): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.soloed = soloed;\n this._adapter?.setTrackSolo(trackId, soloed);\n }\n\n setTrackPan(trackId: string, pan: number): void {\n const track = this._tracks.find((t) => t.id === trackId);\n if (track) track.pan = pan;\n this._adapter?.setTrackPan(trackId, pan);\n }\n\n // ---------------------------------------------------------------------------\n // Zoom\n // ---------------------------------------------------------------------------\n\n zoomIn(): void {\n if (this._zoomIndex > 0) {\n this._zoomIndex--;\n this._emitStateChange();\n }\n }\n\n zoomOut(): void {\n if (this._zoomIndex < this._zoomLevels.length - 1) {\n this._zoomIndex++;\n this._emitStateChange();\n }\n }\n\n setZoomLevel(samplesPerPixel: number): void {\n const newIndex = findClosestZoomIndex(samplesPerPixel, this._zoomLevels);\n if (newIndex === this._zoomIndex) return;\n this._zoomIndex = newIndex;\n this._emitStateChange();\n }\n\n // ---------------------------------------------------------------------------\n // Events\n // ---------------------------------------------------------------------------\n\n on<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n if (!this._listeners.has(event)) {\n this._listeners.set(event, new Set());\n }\n this._listeners.get(event)!.add(listener);\n }\n\n off<K extends EventName>(event: K, listener: EngineEvents[K]): void {\n this._listeners.get(event)?.delete(listener);\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n dispose(): void {\n if (this._disposed) return;\n this._disposed = true;\n this._stopTimeUpdateLoop();\n try {\n this._adapter?.dispose();\n } catch (err) {\n console.warn('[waveform-playlist/engine] Error disposing adapter:', err);\n }\n this._listeners.clear();\n }\n\n // ---------------------------------------------------------------------------\n // Private helpers\n // ---------------------------------------------------------------------------\n\n private _snapshotTracks(): ClipTrack[] {\n return this._tracks.map((t) => ({ ...t, clips: t.clips.map((c) => ({ ...c })) }));\n }\n\n private _pushUndoSnapshot(): void {\n if (this._inTransaction) {\n this._transactionMutated = true;\n return;\n }\n this._undoStack.push(this._snapshotTracks());\n if (this._undoStack.length > this.undoLimit) {\n this._undoStack.shift();\n }\n this._redoStack = [];\n }\n\n private _restoreTracks(snapshot: ClipTrack[]): void {\n const oldTracks = this._tracks;\n this._tracks = snapshot;\n this._tracksVersion++;\n // Use incremental adapter updates when track count is unchanged —\n // avoids full playout rebuild that interrupts playback during undo/redo.\n if (this._adapter && oldTracks.length === snapshot.length) {\n for (let i = 0; i < snapshot.length; i++) {\n if (oldTracks[i] !== snapshot[i]) {\n this._updateTrackOnAdapter(snapshot[i].id);\n }\n }\n } else {\n this._adapter?.setTracks(this._tracks);\n }\n this._emitStateChange();\n }\n\n private _emit(event: string, ...args: unknown[]): void {\n const listeners = this._listeners.get(event);\n if (listeners) {\n for (const listener of listeners) {\n try {\n listener(...args);\n } catch (error) {\n console.warn('[waveform-playlist/engine] Error in event listener:', error);\n }\n }\n }\n }\n\n /**\n * Returns whether the current playback position is before loopEnd.\n * Used by setLoopEnabled/setLoopRegion during playback — if past loopEnd,\n * Transport loop stays off so playback continues to the end.\n * Note: play() uses an inline check instead — _isPlaying is still false\n * when play() runs, and this method returns true unconditionally when\n * not playing.\n */\n private _isBeforeLoopEnd(): boolean {\n if (!this._isPlaying) return true;\n const t = this._adapter?.getCurrentTime() ?? this._currentTime;\n return t < this._loopEnd;\n }\n\n private _emitStateChange(): void {\n this._emit('statechange', this.getState());\n }\n\n private _startTimeUpdateLoop(): void {\n // Guard for Node.js / SSR environments where RAF is unavailable\n if (typeof requestAnimationFrame === 'undefined') return;\n\n this._stopTimeUpdateLoop();\n\n const tick = () => {\n if (this._disposed || !this._isPlaying) return;\n if (this._adapter) {\n this._currentTime = this._adapter.getCurrentTime();\n this._emit('timeupdate', this._currentTime);\n }\n this._animFrameId = requestAnimationFrame(tick);\n };\n\n this._animFrameId = requestAnimationFrame(tick);\n }\n\n private _stopTimeUpdateLoop(): void {\n if (this._animFrameId !== null && typeof cancelAnimationFrame !== 'undefined') {\n cancelAnimationFrame(this._animFrameId);\n this._animFrameId = null;\n }\n }\n}\n"],"mappings":";AAQA,SAAS,kBAAkB;AAYpB,SAAS,kBACd,MACA,cACA,aACA,WACQ;AACR,MAAI,QAAQ;AAGZ,QAAM,WAAW,CAAC,KAAK;AACvB,UAAQ,KAAK,IAAI,OAAO,QAAQ;AAGhC,MAAI,YAAY,GAAG;AACjB,UAAM,WAAW,YAAY,YAAY,CAAC;AAC1C,UAAM,cAAc,SAAS,cAAc,SAAS;AAEpD,UAAM,eAAe,cAAc,KAAK;AACxC,YAAQ,KAAK,IAAI,OAAO,YAAY;AAAA,EACtC;AAGA,MAAI,YAAY,YAAY,SAAS,GAAG;AACtC,UAAM,WAAW,YAAY,YAAY,CAAC;AAE1C,UAAM,eAAe,SAAS,eAAe,KAAK,cAAc,KAAK;AACrE,YAAQ,KAAK,IAAI,OAAO,YAAY;AAAA,EACtC;AAEA,SAAO;AACT;AAmBO,SAAS,sBACd,MACA,cACA,UACA,aACA,WACA,oBACQ;AACR,MAAI,QAAQ;AAEZ,MAAI,aAAa,QAAQ;AAEvB,YAAQ,KAAK,IAAI,OAAO,CAAC,KAAK,WAAW;AAGzC,YAAQ,KAAK,IAAI,OAAO,CAAC,KAAK,aAAa;AAG3C,QAAI,YAAY,GAAG;AACjB,YAAM,WAAW,YAAY,YAAY,CAAC;AAC1C,YAAM,cAAc,SAAS,cAAc,SAAS;AAEpD,cAAQ,KAAK,IAAI,OAAO,cAAc,KAAK,WAAW;AAAA,IACxD;AAIA,YAAQ,KAAK,IAAI,OAAO,KAAK,kBAAkB,kBAAkB;AAAA,EACnE,OAAO;AAKL,YAAQ,KAAK,IAAI,OAAO,qBAAqB,KAAK,eAAe;AAIjE,YAAQ,KAAK,IAAI,OAAO,KAAK,wBAAwB,KAAK,gBAAgB,KAAK,eAAe;AAG9F,QAAI,YAAY,YAAY,SAAS,GAAG;AACtC,YAAM,WAAW,YAAY,YAAY,CAAC;AAE1C,cAAQ,KAAK,IAAI,OAAO,SAAS,cAAc,KAAK,cAAc,KAAK,eAAe;AAAA,IACxF;AAAA,EACF;AAEA,SAAO;AACT;AASO,SAAS,oBAAoB,aAAqB,iBAAiC;AACxF,SAAO,KAAK,MAAM,cAAc,eAAe,IAAI;AACrD;AAaO,SAAS,UACd,MACA,aACuC;AACvC,QAAM,eAAe,cAAc,KAAK;AACxC,QAAM,gBAAgB,KAAK,kBAAkB;AAE7C,QAAM,WAAW,KAAK,OAAO,GAAG,KAAK,IAAI,SAAS;AAClD,QAAM,YAAY,KAAK,OAAO,GAAG,KAAK,IAAI,SAAS;AAEnD,QAAM,OAAO,WAAW;AAAA,IACtB,aAAa,KAAK;AAAA,IAClB,iBAAiB;AAAA,IACjB,eAAe,KAAK;AAAA,IACpB,YAAY,KAAK;AAAA,IACjB,uBAAuB,KAAK;AAAA,IAC5B,MAAM,KAAK;AAAA,IACX,MAAM;AAAA,IACN,OAAO,KAAK;AAAA,IACZ,QAAQ,KAAK;AAAA,IACb,aAAa,KAAK;AAAA,IAClB,cAAc,KAAK;AAAA,EACrB,CAAC;AAED,QAAM,QAAQ,WAAW;AAAA,IACvB,aAAa;AAAA,IACb,iBAAiB;AAAA,IACjB,eAAe,KAAK,gBAAgB;AAAA,IACpC,YAAY,KAAK;AAAA,IACjB,uBAAuB,KAAK;AAAA,IAC5B,MAAM,KAAK;AAAA,IACX,MAAM;AAAA,IACN,OAAO,KAAK;AAAA,IACZ,SAAS,KAAK;AAAA,IACd,aAAa,KAAK;AAAA,IAClB,cAAc,KAAK;AAAA,EACrB,CAAC;AAED,SAAO,EAAE,MAAM,MAAM;AACvB;AAaO,SAAS,WAAW,MAAiB,QAAgB,oBAAqC;AAC/F,QAAM,UAAU,KAAK,cAAc,KAAK;AAGxC,MAAI,UAAU,KAAK,eAAe,UAAU,SAAS;AACnD,WAAO;AAAA,EACT;AAGA,QAAM,eAAe,SAAS,KAAK;AACnC,QAAM,gBAAgB,UAAU;AAEhC,SAAO,gBAAgB,sBAAsB,iBAAiB;AAChE;;;AC5LO,SAAS,wBACd,YACA,gBACA,cAAsB,KACwB;AAC9C,QAAM,SAAS,iBAAiB;AAChC,SAAO;AAAA,IACL,cAAc,KAAK,IAAI,GAAG,aAAa,MAAM;AAAA,IAC7C,YAAY,aAAa,iBAAiB;AAAA,EAC5C;AACF;AAeO,SAAS,uBACd,YACA,YACA,cACA,YACU;AACV,QAAM,cAAc,KAAK,KAAK,aAAa,UAAU;AACrD,QAAM,UAAoB,CAAC;AAE3B,WAAS,IAAI,GAAG,IAAI,aAAa,KAAK;AACpC,UAAM,YAAY,IAAI;AACtB,UAAM,iBAAiB,KAAK,IAAI,aAAa,WAAW,UAAU;AAClE,UAAM,WAAW,YAAY;AAE7B,QAAI,YAAY,gBAAgB,aAAa,YAAY;AACvD;AAAA,IACF;AAEA,YAAQ,KAAK,CAAC;AAAA,EAChB;AAEA,SAAO;AACT;AAcO,SAAS,qBACd,eACA,eACA,YAAoB,KACX;AACT,SAAO,KAAK,IAAI,gBAAgB,aAAa,KAAK;AACpD;;;AC3EO,SAAS,kBAAkB,QAA6B;AAC7D,MAAI,cAAc;AAClB,aAAW,SAAS,QAAQ;AAC1B,eAAW,QAAQ,MAAM,OAAO;AAC9B,YAAM,gBAAgB,KAAK,cAAc,KAAK;AAC9C,YAAM,UAAU,gBAAgB,KAAK;AACrC,oBAAc,KAAK,IAAI,aAAa,OAAO;AAAA,IAC7C;AAAA,EACF;AACA,SAAO;AACT;AAWO,SAAS,qBAAqB,uBAA+B,YAA8B;AAChG,MAAI,WAAW,WAAW,EAAG,QAAO;AAEpC,MAAI,YAAY;AAChB,MAAI,WAAW,KAAK,IAAI,WAAW,CAAC,IAAI,qBAAqB;AAE7D,WAAS,IAAI,GAAG,IAAI,WAAW,QAAQ,KAAK;AAC1C,UAAM,OAAO,KAAK,IAAI,WAAW,CAAC,IAAI,qBAAqB;AAC3D,QAAI,OAAO,UAAU;AACnB,iBAAW;AACX,kBAAY;AAAA,IACd;AAAA,EACF;AAEA,SAAO;AACT;AAeO,SAAS,4BACd,oBACA,oBACA,YACA,gBACA,YACA,eAAuB,GACf;AACR,QAAM,cAAc,aAAa,iBAAiB,IAAI;AACtD,QAAM,aAAc,cAAc,qBAAsB;AACxD,QAAM,iBAAkB,aAAa,aAAc;AACnD,QAAM,gBAAgB,iBAAiB,eAAe,iBAAiB;AACvE,SAAO,KAAK,IAAI,GAAG,aAAa;AAClC;AASO,SAAS,kBAAkB,MAAc,UAA0B;AACxE,SAAO,KAAK,IAAI,GAAG,KAAK,IAAI,MAAM,QAAQ,CAAC;AAC7C;;;AC7EA,SAAS,uBAAuB;AAUhC,IAAM,sBAAsB;AAC5B,IAAM,4BAA4B;AAClC,IAAM,sBAAsB,CAAC,KAAK,KAAK,MAAM,MAAM,MAAM,IAAI;AAC7D,IAAM,+BAA+B;AAI9B,IAAM,iBAAN,MAAqB;AAAA,EA6B1B,YAAY,UAAiC,CAAC,GAAG;AA5BjD,SAAQ,UAAuB,CAAC;AAChC,SAAQ,eAAe;AACvB,SAAQ,qBAAqB;AAC7B,SAAQ,aAAa;AACrB,SAAQ,mBAAkC;AAI1C,SAAQ,kBAAkB;AAC1B,SAAQ,gBAAgB;AACxB,SAAQ,gBAAgB;AACxB,SAAQ,aAAa;AACrB,SAAQ,WAAW;AACnB,SAAQ,iBAAiB;AACzB,SAAQ,iBAAiB;AAEzB,SAAQ,eAA8B;AACtC,SAAQ,YAAY;AAEpB;AAAA,SAAQ,aAAyC,oBAAI,IAAI;AAEzD,SAAQ,aAA4B,CAAC;AACrC,SAAQ,aAA4B,CAAC;AACrC,SAAQ,iBAAiB;AACzB,SAAQ,uBAA2C;AACnD,SAAQ,sBAAsB;AAI5B,SAAK,cAAc,QAAQ,cAAc;AACzC,SAAK,cAAc,CAAC,GAAI,QAAQ,cAAc,mBAAoB;AAClE,SAAK,WAAW,QAAQ,WAAW;AACnC,SAAK,YAAY,QAAQ,aAAa;AAEtC,QAAI,KAAK,YAAY,WAAW,GAAG;AACjC,YAAM,IAAI,MAAM,8CAA8C;AAAA,IAChE;AAEA,UAAM,aAAa,QAAQ,mBAAmB;AAC9C,UAAM,YAAY,KAAK,YAAY,QAAQ,UAAU;AACrD,QAAI,cAAc,IAAI;AACpB,YAAM,IAAI;AAAA,QACR,mCAAmC,UAAU,0BAA0B,KAAK,YAAY,KAAK,IAAI,CAAC,gFACnB,UAAU;AAAA,MAC3F;AAAA,IACF;AACA,SAAK,aAAa;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA,EAMA,IAAI,UAAmB;AACrB,WAAO,KAAK,WAAW,SAAS;AAAA,EAClC;AAAA,EAEA,IAAI,UAAmB;AACrB,WAAO,KAAK,WAAW,SAAS;AAAA,EAClC;AAAA,EAEA,OAAa;AACX,QAAI,KAAK,WAAW,WAAW,EAAG;AAClC,UAAM,WAAW,KAAK,WAAW,IAAI;AACrC,SAAK,WAAW,KAAK,KAAK,gBAAgB,CAAC;AAC3C,SAAK,eAAe,QAAQ;AAAA,EAC9B;AAAA,EAEA,OAAa;AACX,QAAI,KAAK,WAAW,WAAW,EAAG;AAClC,UAAM,WAAW,KAAK,WAAW,IAAI;AACrC,SAAK,WAAW,KAAK,KAAK,gBAAgB,CAAC;AAC3C,SAAK,eAAe,QAAQ;AAAA,EAC9B;AAAA,EAEA,eAAqB;AACnB,SAAK,aAAa,CAAC;AACnB,SAAK,aAAa,CAAC;AAAA,EACrB;AAAA,EAEA,mBAAyB;AACvB,QAAI,KAAK,gBAAgB;AACvB,cAAQ;AAAA,QACN;AAAA,MAEF;AAAA,IACF;AACA,SAAK,uBAAuB,KAAK,gBAAgB;AACjD,SAAK,iBAAiB;AACtB,SAAK,sBAAsB;AAAA,EAC7B;AAAA,EAEA,oBAA0B;AACxB,QAAI,CAAC,KAAK,kBAAkB,KAAK,yBAAyB,MAAM;AAC9D,cAAQ,KAAK,+EAA+E;AAC5F;AAAA,IACF;AAGA,QAAI,KAAK,qBAAqB;AAC5B,WAAK,WAAW,KAAK,KAAK,oBAAoB;AAC9C,UAAI,KAAK,WAAW,SAAS,KAAK,WAAW;AAC3C,aAAK,WAAW,MAAM;AAAA,MACxB;AACA,WAAK,aAAa,CAAC;AAAA,IACrB;AACA,SAAK,uBAAuB;AAC5B,SAAK,iBAAiB;AACtB,SAAK,sBAAsB;AAAA,EAC7B;AAAA,EAEA,mBAAyB;AACvB,QAAI,CAAC,KAAK,kBAAkB,KAAK,yBAAyB,MAAM;AAC9D,cAAQ,KAAK,6EAA6E;AAC1F;AAAA,IACF;AACA,UAAM,WAAW,KAAK;AACtB,UAAM,UAAU,KAAK;AACrB,SAAK,uBAAuB;AAC5B,SAAK,iBAAiB;AACtB,SAAK,sBAAsB;AAE3B,QAAI,SAAS;AACX,WAAK,eAAe,QAAQ;AAAA,IAC9B;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,WAAwB;AACtB,WAAO;AAAA,MACL,QAAQ,KAAK,QAAQ,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,KAAK,EAAE,EAAE;AAAA,MAC/D,eAAe,KAAK;AAAA,MACpB,UAAU,kBAAkB,KAAK,OAAO;AAAA,MACxC,aAAa,KAAK;AAAA,MAClB,WAAW,KAAK;AAAA,MAChB,iBAAiB,KAAK,YAAY,KAAK,UAAU;AAAA,MACjD,YAAY,KAAK;AAAA,MACjB,iBAAiB,KAAK;AAAA,MACtB,WAAW,KAAK;AAAA,MAChB,WAAW,KAAK,aAAa;AAAA,MAC7B,YAAY,KAAK,aAAa,KAAK,YAAY,SAAS;AAAA,MACxD,gBAAgB,KAAK;AAAA,MACrB,cAAc,KAAK;AAAA,MACnB,cAAc,KAAK;AAAA,MACnB,WAAW,KAAK;AAAA,MAChB,SAAS,KAAK;AAAA,MACd,eAAe,KAAK;AAAA,MACpB,SAAS,KAAK;AAAA,MACd,SAAS,KAAK;AAAA,IAChB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAMA,UAAU,QAA2B;AACnC,SAAK,aAAa;AAClB,SAAK,UAAU,CAAC,GAAG,MAAM;AACzB,SAAK;AACL,SAAK,UAAU,UAAU,KAAK,OAAO;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SAAS,OAAwB;AAC/B,SAAK,kBAAkB;AACvB,SAAK,UAAU,CAAC,GAAG,KAAK,SAAS,KAAK;AACtC,SAAK;AACL,QAAI,KAAK,UAAU,UAAU;AAC3B,WAAK,SAAS,SAAS,KAAK;AAAA,IAC9B,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,YAAY,SAAuB;AACjC,QAAI,CAAC,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO,EAAG;AACjD,SAAK,kBAAkB;AACvB,SAAK,UAAU,KAAK,QAAQ,OAAO,CAAC,MAAM,EAAE,OAAO,OAAO;AAC1D,SAAK;AACL,QAAI,KAAK,qBAAqB,SAAS;AACrC,WAAK,mBAAmB;AAAA,IAC1B;AACA,QAAI,KAAK,UAAU,aAAa;AAC9B,WAAK,SAAS,YAAY,OAAO;AAAA,IACnC,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA,EAGA,YAAY,SAAiB,OAAyB;AACpD,UAAM,WAAW,SAAS,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACnE,QAAI,CAAC,SAAU;AACf,QAAI,OAAO;AACT,WAAK,kBAAkB;AACvB,WAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAO,EAAE,OAAO,UAAU,QAAQ,CAAE;AACrE,WAAK;AAAA,IACP;AACA,QAAI,KAAK,UAAU,aAAa;AAC9B,WAAK,SAAS,YAAY,SAAS,QAAQ;AAAA,IAC7C,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AAEA,QAAI,MAAO,MAAK,iBAAiB;AAAA,EACnC;AAAA;AAAA,EAGQ,sBAAsB,SAAuB;AACnD,UAAM,IAAI,KAAK,QAAQ,KAAK,CAAC,OAAO,GAAG,OAAO,OAAO;AACrD,QAAI,CAAC,EAAG;AACR,QAAI,KAAK,UAAU,aAAa;AAC9B,WAAK,SAAS,YAAY,SAAS,CAAC;AAAA,IACtC,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AAAA,EACF;AAAA,EAEA,YAAY,SAA8B;AACxC,QAAI,YAAY,KAAK,iBAAkB;AACvC,SAAK,mBAAmB;AACxB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,cACE,SACA,QAMO;AACP,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO,QAAO;AACnB,UAAM,OAAO,MAAM,MAAM,KAAK,CAAC,MAAiB,EAAE,OAAO,MAAM;AAC/D,QAAI,CAAC,KAAM,QAAO;AAClB,WAAO;AAAA,MACL,eAAe,KAAK;AAAA,MACpB,iBAAiB,KAAK;AAAA,MACtB,aAAa,KAAK;AAAA,MAClB,uBAAuB,KAAK;AAAA,IAC9B;AAAA,EACF;AAAA;AAAA;AAAA,EAIA,mBACE,SACA,QACA,UACA,cACQ;AACR,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,MAAO,QAAO;AACnB,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,GAAI,QAAO;AAE7B,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,gBAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAC3E,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,SAAS,SAAiB,QAAgB,cAAsB,cAAc,OAAe;AAC3F,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,OAAO;AACV,cAAQ,KAAK,+CAA+C,OAAO,aAAa;AAChF,aAAO;AAAA,IACT;AAEA,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,IAAI;AACpB,cAAQ;AAAA,QACN,8CAA8C,MAAM,yBAAyB,OAAO;AAAA,MACtF;AACA,aAAO;AAAA,IACT;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,gBAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAE3E,UAAM,mBAAmB,kBAAkB,MAAM,cAAc,aAAa,WAAW;AAEvF,QAAI,qBAAqB,EAAG,QAAO;AAEnC,SAAK,kBAAkB;AAEvB,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,EAAE,MAAM;AAAA,QAAI,CAAC,GAAc,MAC1C,MAAM,YACF;AAAA,UACE,GAAG;AAAA,UACH,aAAa,KAAK,MAAM,EAAE,cAAc,gBAAgB;AAAA,QAC1D,IACA;AAAA,MACN;AACA,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK;AACL,QAAI,CAAC,aAAa;AAChB,WAAK,sBAAsB,OAAO;AAAA,IACpC;AACA,SAAK,iBAAiB;AACtB,WAAO;AAAA,EACT;AAAA,EAEA,UAAU,SAAiB,QAAgB,UAAwB;AACjE,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,OAAO;AACV,cAAQ,KAAK,gDAAgD,OAAO,aAAa;AACjF;AAAA,IACF;AAEA,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,IAAI;AACpB,cAAQ;AAAA,QACN,+CAA+C,MAAM,yBAAyB,OAAO;AAAA,MACvF;AACA;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,QAAI,CAAC,WAAW,MAAM,UAAU,WAAW,GAAG;AAC5C,cAAQ;AAAA,QACN,4DAA4D,MAAM,eAAe,QAAQ,iBACvE,KAAK,WAAW,SAAI,KAAK,cAAc,KAAK,eAAe,kBAAkB,WAAW;AAAA,MAC5G;AACA;AAAA,IACF;AAEA,SAAK,kBAAkB;AAEvB,UAAM,EAAE,MAAM,MAAM,IAAI,UAAY,MAAM,QAAQ;AAElD,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,CAAC,GAAG,EAAE,KAAK;AAC5B,eAAS,OAAO,WAAW,GAAG,MAAM,KAAK;AACzC,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK;AACL,SAAK,sBAAsB,OAAO;AAClC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,SACE,SACA,QACA,UACA,cACA,cAAc,OACR;AACN,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,CAAC,OAAO;AACV,cAAQ,KAAK,+CAA+C,OAAO,aAAa;AAChF;AAAA,IACF;AAEA,UAAM,YAAY,MAAM,MAAM,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AACzE,QAAI,cAAc,IAAI;AACpB,cAAQ;AAAA,QACN,8CAA8C,MAAM,yBAAyB,OAAO;AAAA,MACtF;AACA;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS;AAClC,UAAM,cAAc,gBAAgB,MAAM,KAAK;AAC/C,UAAM,cAAc,YAAY,UAAU,CAAC,MAAiB,EAAE,OAAO,MAAM;AAC3E,UAAM,cAAc,KAAK,MAAM,+BAA+B,KAAK,WAAW;AAE9E,UAAM,cAAc;AAAA,MAClB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAEA,QAAI,gBAAgB,EAAG;AAEvB,SAAK,kBAAkB;AAEvB,SAAK,UAAU,KAAK,QAAQ,IAAI,CAAC,MAAM;AACrC,UAAI,EAAE,OAAO,QAAS,QAAO;AAC7B,YAAM,WAAW,EAAE,MAAM,IAAI,CAAC,GAAc,MAAc;AACxD,YAAI,MAAM,UAAW,QAAO;AAC5B,YAAI,aAAa,QAAQ;AACvB,iBAAO;AAAA,YACL,GAAG;AAAA,YACH,aAAa,EAAE,cAAc;AAAA,YAC7B,eAAe,EAAE,gBAAgB;AAAA,YACjC,iBAAiB,EAAE,kBAAkB;AAAA,UACvC;AAAA,QACF,OAAO;AACL,iBAAO,EAAE,GAAG,GAAG,iBAAiB,EAAE,kBAAkB,YAAY;AAAA,QAClE;AAAA,MACF,CAAC;AACD,aAAO,EAAE,GAAG,GAAG,OAAO,SAAS;AAAA,IACjC,CAAC;AAED,SAAK;AACL,QAAI,CAAC,aAAa;AAChB,WAAK,sBAAsB,OAAO;AAAA,IACpC;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,OAAsB;AAC1B,QAAI,KAAK,UAAU;AACjB,YAAM,KAAK,SAAS,KAAK;AAAA,IAC3B;AAAA,EACF;AAAA,EAEA,KAAK,WAAoB,SAAwB;AAC/C,UAAM,kBAAkB,KAAK;AAC7B,UAAM,wBAAwB,KAAK;AAEnC,QAAI,cAAc,QAAW;AAC3B,WAAK,eAAe,KAAK,IAAI,GAAG,SAAS;AAAA,IAC3C;AAGA,SAAK,qBAAqB,KAAK;AAE/B,QAAI,KAAK,UAAU;AAKjB,UAAI,YAAY,QAAW;AAEzB,aAAK,SAAS,QAAQ,OAAO,KAAK,YAAY,KAAK,QAAQ;AAAA,MAC7D,WAAW,KAAK,gBAAgB;AAG9B,cAAM,gBAAgB,KAAK,eAAe,KAAK;AAC/C,aAAK,SAAS,QAAQ,eAAe,KAAK,YAAY,KAAK,QAAQ;AAAA,MACrE;AACA,UAAI;AACF,aAAK,SAAS,KAAK,KAAK,cAAc,OAAO;AAAA,MAC/C,SAAS,KAAK;AAGZ,aAAK,eAAe;AACpB,aAAK,qBAAqB;AAC1B,cAAM;AAAA,MACR;AAAA,IACF;AAEA,SAAK,aAAa;AAClB,SAAK,qBAAqB;AAC1B,SAAK,MAAM,MAAM;AACjB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,QAAc;AACZ,SAAK,aAAa;AAClB,SAAK,oBAAoB;AACzB,SAAK,UAAU,MAAM;AACrB,QAAI,KAAK,UAAU;AACjB,WAAK,eAAe,KAAK,SAAS,eAAe;AAAA,IACnD;AACA,SAAK,MAAM,OAAO;AAClB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,OAAa;AACX,SAAK,aAAa;AAClB,SAAK,eAAe,KAAK;AACzB,SAAK,oBAAoB;AACzB,SAAK,UAAU,QAAQ,OAAO,KAAK,YAAY,KAAK,QAAQ;AAC5D,SAAK,UAAU,KAAK;AACpB,SAAK,MAAM,MAAM;AACjB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,KAAK,MAAoB;AACvB,SAAK,eAAe,KAAK,IAAI,GAAG,IAAI;AACpC,SAAK,UAAU,KAAK,KAAK,YAAY;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,gBAAgB,QAAsB;AACpC,QAAI,WAAW,KAAK,cAAe;AACnC,SAAK,gBAAgB;AACrB,SAAK,UAAU,gBAAgB,MAAM;AACrC,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,iBAAyB;AACvB,QAAI,KAAK,cAAc,KAAK,UAAU;AACpC,aAAO,KAAK,SAAS,eAAe;AAAA,IACtC;AACA,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA,EAMA,aAAa,OAAe,KAAmB;AAC7C,UAAM,IAAI,KAAK,IAAI,OAAO,GAAG;AAC7B,UAAM,IAAI,KAAK,IAAI,OAAO,GAAG;AAC7B,QAAI,MAAM,KAAK,mBAAmB,MAAM,KAAK,cAAe;AAC5D,SAAK,kBAAkB;AACvB,SAAK,gBAAgB;AACrB,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,cAAc,OAAe,KAAmB;AAC9C,UAAM,IAAI,KAAK,IAAI,OAAO,GAAG;AAC7B,UAAM,IAAI,KAAK,IAAI,OAAO,GAAG;AAC7B,QAAI,MAAM,KAAK,cAAc,MAAM,KAAK,SAAU;AAClD,SAAK,aAAa;AAClB,SAAK,WAAW;AAChB,SAAK,UAAU;AAAA,MACb,KAAK,kBAAkB,KAAK,iBAAiB;AAAA,MAC7C,KAAK;AAAA,MACL,KAAK;AAAA,IACP;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEA,eAAe,SAAwB;AACrC,QAAI,YAAY,KAAK,eAAgB;AACrC,SAAK,iBAAiB;AACtB,SAAK,UAAU,QAAQ,WAAW,KAAK,iBAAiB,GAAG,KAAK,YAAY,KAAK,QAAQ;AACzF,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,eAAe,SAAiB,QAAsB;AACpD,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,MAAO,OAAM,SAAS;AAC1B,SAAK,UAAU,eAAe,SAAS,MAAM;AAAA,EAC/C;AAAA,EAEA,aAAa,SAAiB,OAAsB;AAClD,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,MAAO,OAAM,QAAQ;AACzB,SAAK,UAAU,aAAa,SAAS,KAAK;AAAA,EAC5C;AAAA,EAEA,aAAa,SAAiB,QAAuB;AACnD,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,MAAO,OAAM,SAAS;AAC1B,SAAK,UAAU,aAAa,SAAS,MAAM;AAAA,EAC7C;AAAA,EAEA,YAAY,SAAiB,KAAmB;AAC9C,UAAM,QAAQ,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO;AACvD,QAAI,MAAO,OAAM,MAAM;AACvB,SAAK,UAAU,YAAY,SAAS,GAAG;AAAA,EACzC;AAAA;AAAA;AAAA;AAAA,EAMA,SAAe;AACb,QAAI,KAAK,aAAa,GAAG;AACvB,WAAK;AACL,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,QAAI,KAAK,aAAa,KAAK,YAAY,SAAS,GAAG;AACjD,WAAK;AACL,WAAK,iBAAiB;AAAA,IACxB;AAAA,EACF;AAAA,EAEA,aAAa,iBAA+B;AAC1C,UAAM,WAAW,qBAAqB,iBAAiB,KAAK,WAAW;AACvE,QAAI,aAAa,KAAK,WAAY;AAClC,SAAK,aAAa;AAClB,SAAK,iBAAiB;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMA,GAAwB,OAAU,UAAiC;AACjE,QAAI,CAAC,KAAK,WAAW,IAAI,KAAK,GAAG;AAC/B,WAAK,WAAW,IAAI,OAAO,oBAAI,IAAI,CAAC;AAAA,IACtC;AACA,SAAK,WAAW,IAAI,KAAK,EAAG,IAAI,QAAQ;AAAA,EAC1C;AAAA,EAEA,IAAyB,OAAU,UAAiC;AAClE,SAAK,WAAW,IAAI,KAAK,GAAG,OAAO,QAAQ;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA,EAMA,UAAgB;AACd,QAAI,KAAK,UAAW;AACpB,SAAK,YAAY;AACjB,SAAK,oBAAoB;AACzB,QAAI;AACF,WAAK,UAAU,QAAQ;AAAA,IACzB,SAAS,KAAK;AACZ,cAAQ,KAAK,uDAAuD,GAAG;AAAA,IACzE;AACA,SAAK,WAAW,MAAM;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA,EAMQ,kBAA+B;AACrC,WAAO,KAAK,QAAQ,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,OAAO,EAAE,MAAM,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,EAAE;AAAA,EAClF;AAAA,EAEQ,oBAA0B;AAChC,QAAI,KAAK,gBAAgB;AACvB,WAAK,sBAAsB;AAC3B;AAAA,IACF;AACA,SAAK,WAAW,KAAK,KAAK,gBAAgB,CAAC;AAC3C,QAAI,KAAK,WAAW,SAAS,KAAK,WAAW;AAC3C,WAAK,WAAW,MAAM;AAAA,IACxB;AACA,SAAK,aAAa,CAAC;AAAA,EACrB;AAAA,EAEQ,eAAe,UAA6B;AAClD,UAAM,YAAY,KAAK;AACvB,SAAK,UAAU;AACf,SAAK;AAGL,QAAI,KAAK,YAAY,UAAU,WAAW,SAAS,QAAQ;AACzD,eAAS,IAAI,GAAG,IAAI,SAAS,QAAQ,KAAK;AACxC,YAAI,UAAU,CAAC,MAAM,SAAS,CAAC,GAAG;AAChC,eAAK,sBAAsB,SAAS,CAAC,EAAE,EAAE;AAAA,QAC3C;AAAA,MACF;AAAA,IACF,OAAO;AACL,WAAK,UAAU,UAAU,KAAK,OAAO;AAAA,IACvC;AACA,SAAK,iBAAiB;AAAA,EACxB;AAAA,EAEQ,MAAM,UAAkB,MAAuB;AACrD,UAAM,YAAY,KAAK,WAAW,IAAI,KAAK;AAC3C,QAAI,WAAW;AACb,iBAAW,YAAY,WAAW;AAChC,YAAI;AACF,mBAAS,GAAG,IAAI;AAAA,QAClB,SAAS,OAAO;AACd,kBAAQ,KAAK,uDAAuD,KAAK;AAAA,QAC3E;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUQ,mBAA4B;AAClC,QAAI,CAAC,KAAK,WAAY,QAAO;AAC7B,UAAM,IAAI,KAAK,UAAU,eAAe,KAAK,KAAK;AAClD,WAAO,IAAI,KAAK;AAAA,EAClB;AAAA,EAEQ,mBAAyB;AAC/B,SAAK,MAAM,eAAe,KAAK,SAAS,CAAC;AAAA,EAC3C;AAAA,EAEQ,uBAA6B;AAEnC,QAAI,OAAO,0BAA0B,YAAa;AAElD,SAAK,oBAAoB;AAEzB,UAAM,OAAO,MAAM;AACjB,UAAI,KAAK,aAAa,CAAC,KAAK,WAAY;AACxC,UAAI,KAAK,UAAU;AACjB,aAAK,eAAe,KAAK,SAAS,eAAe;AACjD,aAAK,MAAM,cAAc,KAAK,YAAY;AAAA,MAC5C;AACA,WAAK,eAAe,sBAAsB,IAAI;AAAA,IAChD;AAEA,SAAK,eAAe,sBAAsB,IAAI;AAAA,EAChD;AAAA,EAEQ,sBAA4B;AAClC,QAAI,KAAK,iBAAiB,QAAQ,OAAO,yBAAyB,aAAa;AAC7E,2BAAqB,KAAK,YAAY;AACtC,WAAK,eAAe;AAAA,IACtB;AAAA,EACF;AACF;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waveform-playlist/engine",
|
|
3
|
-
"version": "11.0
|
|
3
|
+
"version": "11.2.0",
|
|
4
4
|
"description": "Framework-agnostic engine for waveform-playlist — pure operations and stateful timeline management",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"tsup": "^8.0.1",
|
|
40
40
|
"typescript": "^5.3.3",
|
|
41
41
|
"vitest": "^3.0.0",
|
|
42
|
-
"@waveform-playlist/core": "11.0
|
|
42
|
+
"@waveform-playlist/core": "11.2.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@waveform-playlist/core": ">=7.0.0"
|