@elah/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/actions/index.d.ts +3 -0
  2. package/dist/actions/index.js +1 -0
  3. package/dist/actions/splitClipAtPlayhead.d.ts +8 -0
  4. package/dist/actions/splitClipAtPlayhead.js +34 -0
  5. package/dist/actions/types.d.ts +8 -0
  6. package/dist/actions/types.js +1 -0
  7. package/dist/assets/importFiles.d.ts +29 -0
  8. package/dist/assets/importFiles.js +351 -0
  9. package/dist/assets/index.d.ts +10 -0
  10. package/dist/assets/index.js +13 -0
  11. package/dist/assets/store.d.ts +13 -0
  12. package/dist/assets/store.js +20 -0
  13. package/dist/assets/types.d.ts +23 -0
  14. package/dist/assets/types.js +1 -0
  15. package/dist/debug/trace.d.ts +20 -0
  16. package/dist/debug/trace.js +98 -0
  17. package/dist/editor/TimelineEngine.d.ts +65 -0
  18. package/dist/editor/TimelineEngine.js +413 -0
  19. package/dist/editor-context.d.ts +10 -0
  20. package/dist/editor-context.js +10 -0
  21. package/dist/elements/audio.d.ts +13 -0
  22. package/dist/elements/audio.js +4 -0
  23. package/dist/elements/base.d.ts +40 -0
  24. package/dist/elements/base.js +39 -0
  25. package/dist/elements/image.d.ts +13 -0
  26. package/dist/elements/image.js +4 -0
  27. package/dist/elements/text.d.ts +12 -0
  28. package/dist/elements/text.js +8 -0
  29. package/dist/elements/video.d.ts +13 -0
  30. package/dist/elements/video.js +4 -0
  31. package/dist/export/ExportWorker.d.ts +1 -0
  32. package/dist/export/ExportWorker.js +371 -0
  33. package/dist/export/exportVideo.d.ts +3 -0
  34. package/dist/export/exportVideo.js +118 -0
  35. package/dist/export/index.d.ts +2 -0
  36. package/dist/export/index.js +1 -0
  37. package/dist/export/lazyExport.d.ts +4 -0
  38. package/dist/export/lazyExport.js +4 -0
  39. package/dist/export/types.d.ts +37 -0
  40. package/dist/export/types.js +1 -0
  41. package/dist/index.d.ts +48 -0
  42. package/dist/index.js +28 -0
  43. package/dist/media/audio/AudioPlaybackController.d.ts +26 -0
  44. package/dist/media/audio/AudioPlaybackController.js +125 -0
  45. package/dist/media/video/DecoderBackedVideoFrameProvider.d.ts +51 -0
  46. package/dist/media/video/DecoderBackedVideoFrameProvider.js +125 -0
  47. package/dist/media/video/FrameCache.d.ts +28 -0
  48. package/dist/media/video/FrameCache.js +88 -0
  49. package/dist/media/video/StreamingFrameProducer.d.ts +57 -0
  50. package/dist/media/video/StreamingFrameProducer.js +356 -0
  51. package/dist/media/video/VideoDecoderManager.d.ts +59 -0
  52. package/dist/media/video/VideoDecoderManager.js +342 -0
  53. package/dist/media/video/VideoFrameProvider.d.ts +101 -0
  54. package/dist/media/video/VideoFrameProvider.js +257 -0
  55. package/dist/media/video/demuxer/MediabunnyDemuxer.d.ts +23 -0
  56. package/dist/media/video/demuxer/MediabunnyDemuxer.js +88 -0
  57. package/dist/media/video/demuxer/createMediabunnyBackend.d.ts +32 -0
  58. package/dist/media/video/demuxer/createMediabunnyBackend.js +156 -0
  59. package/dist/media/video/index.d.ts +8 -0
  60. package/dist/media/video/index.js +5 -0
  61. package/dist/playback/PlaybackEngine.d.ts +50 -0
  62. package/dist/playback/PlaybackEngine.js +188 -0
  63. package/dist/renderer/gpu/GpuRenderer.d.ts +38 -0
  64. package/dist/renderer/gpu/GpuRenderer.js +208 -0
  65. package/dist/renderer/gpu/RenderGraph.d.ts +10 -0
  66. package/dist/renderer/gpu/RenderGraph.js +80 -0
  67. package/dist/renderer/gpu/ShaderProgram.d.ts +14 -0
  68. package/dist/renderer/gpu/ShaderProgram.js +76 -0
  69. package/dist/renderer/gpu/TexturePool.d.ts +25 -0
  70. package/dist/renderer/gpu/TexturePool.js +93 -0
  71. package/dist/renderer/gpu/VideoTexture.d.ts +13 -0
  72. package/dist/renderer/gpu/VideoTexture.js +54 -0
  73. package/dist/renderer/gpu/WebGLContext.d.ts +28 -0
  74. package/dist/renderer/gpu/WebGLContext.js +102 -0
  75. package/dist/renderer/gpu/debug/DebugGpuRenderer.d.ts +27 -0
  76. package/dist/renderer/gpu/debug/DebugGpuRenderer.js +108 -0
  77. package/dist/renderer/gpu/debug/DebugOverlay.d.ts +17 -0
  78. package/dist/renderer/gpu/debug/DebugOverlay.js +83 -0
  79. package/dist/renderer/gpu/debug/GpuDebugCounters.d.ts +38 -0
  80. package/dist/renderer/gpu/debug/GpuDebugCounters.js +72 -0
  81. package/dist/renderer/gpu/debug/GpuDebugGlobal.d.ts +16 -0
  82. package/dist/renderer/gpu/debug/GpuDebugGlobal.js +14 -0
  83. package/dist/renderer/gpu/debug/GpuRendererDebugPanel.d.ts +31 -0
  84. package/dist/renderer/gpu/debug/GpuRendererDebugPanel.js +128 -0
  85. package/dist/renderer/gpu/debug/RecordingGl.d.ts +88 -0
  86. package/dist/renderer/gpu/debug/RecordingGl.js +214 -0
  87. package/dist/renderer/gpu/debug/playground.d.ts +20 -0
  88. package/dist/renderer/gpu/debug/playground.js +64 -0
  89. package/dist/renderer/gpu/debug/scenarios.d.ts +7 -0
  90. package/dist/renderer/gpu/debug/scenarios.js +145 -0
  91. package/dist/renderer/gpu/debug/types.d.ts +16 -0
  92. package/dist/renderer/gpu/debug/types.js +1 -0
  93. package/dist/renderer/gpu/layers/FrameProbeLayer.d.ts +16 -0
  94. package/dist/renderer/gpu/layers/FrameProbeLayer.js +127 -0
  95. package/dist/renderer/gpu/layers/ImageLayer.d.ts +23 -0
  96. package/dist/renderer/gpu/layers/ImageLayer.js +124 -0
  97. package/dist/renderer/gpu/layers/TestLayer.d.ts +16 -0
  98. package/dist/renderer/gpu/layers/TestLayer.js +109 -0
  99. package/dist/renderer/gpu/layers/TextLayer.d.ts +19 -0
  100. package/dist/renderer/gpu/layers/TextLayer.js +166 -0
  101. package/dist/renderer/gpu/layers/VideoLayer.d.ts +38 -0
  102. package/dist/renderer/gpu/layers/VideoLayer.js +194 -0
  103. package/dist/renderer/gpu/layers/drawRect.d.ts +13 -0
  104. package/dist/renderer/gpu/layers/drawRect.js +55 -0
  105. package/dist/renderer/gpu/layers/objectFit.d.ts +7 -0
  106. package/dist/renderer/gpu/layers/objectFit.js +26 -0
  107. package/dist/renderer/gpu/layers/textLayout.d.ts +47 -0
  108. package/dist/renderer/gpu/layers/textLayout.js +82 -0
  109. package/dist/renderer/gpu/layers/types.d.ts +18 -0
  110. package/dist/renderer/gpu/layers/types.js +1 -0
  111. package/dist/renderer/gpu/shaders/quad.frag.d.ts +1 -0
  112. package/dist/renderer/gpu/shaders/quad.frag.js +14 -0
  113. package/dist/renderer/gpu/shaders/quad.vert.d.ts +1 -0
  114. package/dist/renderer/gpu/shaders/quad.vert.js +28 -0
  115. package/dist/renderer/gpu/types.d.ts +21 -0
  116. package/dist/renderer/gpu/types.js +1 -0
  117. package/dist/renderer/gpu/viewport.d.ts +7 -0
  118. package/dist/renderer/gpu/viewport.js +33 -0
  119. package/dist/renderer/types.d.ts +7 -0
  120. package/dist/renderer/types.js +1 -0
  121. package/dist/resolver/resolveTimeline.d.ts +3 -0
  122. package/dist/resolver/resolveTimeline.js +249 -0
  123. package/dist/resolver/scene.d.ts +54 -0
  124. package/dist/resolver/scene.js +1 -0
  125. package/dist/stores/playback.store.d.ts +50 -0
  126. package/dist/stores/playback.store.js +42 -0
  127. package/dist/stores/selection.store.d.ts +12 -0
  128. package/dist/stores/selection.store.js +19 -0
  129. package/dist/stores/tracks.store.d.ts +19 -0
  130. package/dist/stores/tracks.store.js +27 -0
  131. package/dist/stores/transitions.store.d.ts +9 -0
  132. package/dist/stores/transitions.store.js +7 -0
  133. package/dist/track/track.d.ts +8 -0
  134. package/dist/track/track.js +19 -0
  135. package/dist/types/index.d.ts +117 -0
  136. package/dist/types/index.js +1 -0
  137. package/dist/utils/frames.d.ts +20 -0
  138. package/dist/utils/frames.js +40 -0
  139. package/dist/utils/id.d.ts +1 -0
  140. package/dist/utils/id.js +3 -0
  141. package/dist/utils/snap.d.ts +5 -0
  142. package/dist/utils/snap.js +79 -0
  143. package/dist/visitor/add.d.ts +3 -0
  144. package/dist/visitor/add.js +16 -0
  145. package/dist/visitor/clone.d.ts +3 -0
  146. package/dist/visitor/clone.js +25 -0
  147. package/dist/visitor/remove.d.ts +5 -0
  148. package/dist/visitor/remove.js +29 -0
  149. package/dist/visitor/split.d.ts +3 -0
  150. package/dist/visitor/split.js +31 -0
  151. package/dist/visitor/update.d.ts +4 -0
  152. package/dist/visitor/update.js +31 -0
  153. package/package.json +31 -0
@@ -0,0 +1,65 @@
1
+ import type { Clip, EngineEvent, EngineEventPayload, Project, TimelineConfig, Track, TrackKind, Transition, TransitionKind, TransitionEasing, TransitionDirection } from '../types';
2
+ import { type CreateTrackOptions } from '../track/track';
3
+ import { type CreateClipOptions } from '../elements/base';
4
+ type Listener<E extends EngineEvent> = (payload: EngineEventPayload[E]) => void;
5
+ export declare class TimelineEngine {
6
+ private project;
7
+ private undoStack;
8
+ private redoStack;
9
+ private readonly maxHistorySize;
10
+ private readonly defaultTrackHeight;
11
+ private batchDepth;
12
+ private batchPrev;
13
+ private batchDescription;
14
+ private interactionPrev;
15
+ private listeners;
16
+ constructor(config: TimelineConfig);
17
+ getProject(): Project;
18
+ getTrack(trackId: string): Track | undefined;
19
+ getClipsOnTrack(trackId: string): Clip[];
20
+ findClip(clipId: string): {
21
+ clip: Clip;
22
+ trackId: string;
23
+ } | null;
24
+ getTotalFrames(): number;
25
+ canUndo(): boolean;
26
+ canRedo(): boolean;
27
+ setStage(width: number, height: number): void;
28
+ addTrack(kind: TrackKind, options?: Partial<CreateTrackOptions>): Track;
29
+ removeTrack(trackId: string): void;
30
+ updateTrack(trackId: string, updates: Partial<Track>): void;
31
+ reorderTracks(orderedIds: string[]): void;
32
+ addClip(options: CreateClipOptions): Clip;
33
+ removeClip(clipId: string, trackId: string): void;
34
+ updateClip(clipId: string, trackId: string, updates: Partial<Clip>): void;
35
+ previewClip(clipId: string, trackId: string, updates: Partial<Clip>): void;
36
+ commitInteraction(description?: string): void;
37
+ cancelInteraction(): void;
38
+ moveClip(clipId: string, fromTrackId: string, toTrackId: string, startFrame: number): void;
39
+ trimClip(clipId: string, trackId: string, startFrame: number, durationFrames: number): void;
40
+ splitClip(clipId: string, trackId: string, atFrame: number): [string, string] | null;
41
+ cloneClip(clipId: string, trackId: string, startFrame: number): string | null;
42
+ addTransition(options: {
43
+ fromClipId: string;
44
+ toClipId: string;
45
+ trackId: string;
46
+ kind: TransitionKind;
47
+ durationFrames: number;
48
+ easing?: TransitionEasing;
49
+ direction?: TransitionDirection;
50
+ offsetFrames?: number;
51
+ }): Transition | null;
52
+ removeTransition(transitionId: string): void;
53
+ updateTransition(transitionId: string, patch: Partial<Pick<Transition, 'kind' | 'durationFrames' | 'easing' | 'direction'>> & {
54
+ offsetFrames?: number;
55
+ }): void;
56
+ loadProject(project: Project): void;
57
+ batch(recipe: () => void, description?: string): void;
58
+ undo(): boolean;
59
+ redo(): boolean;
60
+ on<E extends EngineEvent>(event: E, listener: Listener<E>): void;
61
+ off<E extends EngineEvent>(event: E, listener: Listener<E>): void;
62
+ private commit;
63
+ private emit;
64
+ }
65
+ export {};
@@ -0,0 +1,413 @@
1
+ import { produce } from 'immer';
2
+ import { generateId } from '../utils/id';
3
+ import { getTotalFrames, toFrame, findOverlaps } from '../utils/frames';
4
+ import { createTrack } from '../track/track';
5
+ import { createClip } from '../elements/base';
6
+ import { addClip } from '../visitor/add';
7
+ import { removeClip, removeTrack, pruneOrphanedTransitions } from '../visitor/remove';
8
+ import { updateClip, updateTrack } from '../visitor/update';
9
+ import { splitClip } from '../visitor/split';
10
+ import { cloneClip } from '../visitor/clone';
11
+ function buildEmptyProject(fps, stage, initialTracks, defaultTrackHeight) {
12
+ const specs = initialTracks && initialTracks.length > 0
13
+ ? initialTracks
14
+ : [{ kind: 'video', name: 'Track 1' }];
15
+ const tracks = specs.map((spec, order) => createTrack({
16
+ kind: spec.kind,
17
+ name: spec.name,
18
+ order,
19
+ height: defaultTrackHeight,
20
+ }));
21
+ const clips = {};
22
+ for (const track of tracks)
23
+ clips[track.id] = [];
24
+ return {
25
+ id: generateId(),
26
+ fps,
27
+ stage,
28
+ tracks,
29
+ clips,
30
+ transitions: [],
31
+ version: 1,
32
+ };
33
+ }
34
+ export class TimelineEngine {
35
+ constructor(config) {
36
+ this.undoStack = [];
37
+ this.redoStack = [];
38
+ this.batchDepth = 0;
39
+ this.batchPrev = null;
40
+ this.batchDescription = null;
41
+ this.interactionPrev = null;
42
+ this.listeners = new Map();
43
+ const stage = config.stage ?? { width: 1080, height: 1920 };
44
+ this.maxHistorySize = config.maxHistorySize ?? 100;
45
+ this.defaultTrackHeight = config.defaultTrackHeight ?? 64;
46
+ this.project = buildEmptyProject(config.fps, stage, config.initialTracks, this.defaultTrackHeight);
47
+ }
48
+ getProject() {
49
+ return this.project;
50
+ }
51
+ getTrack(trackId) {
52
+ return this.project.tracks.find((t) => t.id === trackId);
53
+ }
54
+ getClipsOnTrack(trackId) {
55
+ return this.project.clips[trackId] ?? [];
56
+ }
57
+ findClip(clipId) {
58
+ for (const [trackId, clips] of Object.entries(this.project.clips)) {
59
+ const clip = clips.find((c) => c.id === clipId);
60
+ if (clip)
61
+ return { clip, trackId };
62
+ }
63
+ return null;
64
+ }
65
+ getTotalFrames() {
66
+ return getTotalFrames(this.project.clips);
67
+ }
68
+ canUndo() {
69
+ return this.undoStack.length > 0;
70
+ }
71
+ canRedo() {
72
+ return this.redoStack.length > 0;
73
+ }
74
+ setStage(width, height) {
75
+ this.commit((draft) => {
76
+ draft.stage = { width, height };
77
+ }, 'Change aspect ratio');
78
+ }
79
+ addTrack(kind, options) {
80
+ const order = this.project.tracks.length;
81
+ const track = createTrack({
82
+ kind,
83
+ order,
84
+ height: this.defaultTrackHeight,
85
+ ...options,
86
+ });
87
+ this.commit((draft) => {
88
+ draft.tracks.push(track);
89
+ draft.clips[track.id] = [];
90
+ }, `Add ${kind} track`);
91
+ this.emit('track:added', track);
92
+ return track;
93
+ }
94
+ removeTrack(trackId) {
95
+ this.commit((draft) => removeTrack(draft, trackId), `Remove track`);
96
+ this.emit('track:removed', trackId);
97
+ }
98
+ updateTrack(trackId, updates) {
99
+ this.commit((draft) => updateTrack(draft, trackId, updates), `Update track`);
100
+ }
101
+ reorderTracks(orderedIds) {
102
+ this.commit((draft) => {
103
+ orderedIds.forEach((id, index) => {
104
+ const track = draft.tracks.find((t) => t.id === id);
105
+ if (track)
106
+ track.order = index;
107
+ });
108
+ draft.tracks.sort((a, b) => a.order - b.order);
109
+ }, 'Reorder tracks');
110
+ }
111
+ addClip(options) {
112
+ const clip = createClip(options);
113
+ this.commit((draft) => addClip(draft, clip), `Add ${clip.type} clip`);
114
+ this.emit('clip:added', clip);
115
+ return clip;
116
+ }
117
+ removeClip(clipId, trackId) {
118
+ this.commit((draft) => removeClip(draft, clipId, trackId), `Remove clip`);
119
+ this.emit('clip:removed', { clipId, trackId });
120
+ }
121
+ updateClip(clipId, trackId, updates) {
122
+ this.commit((draft) => updateClip(draft, clipId, trackId, updates), `Update clip`);
123
+ const clip = this.findClip(clipId)?.clip;
124
+ if (clip)
125
+ this.emit('clip:updated', clip);
126
+ }
127
+ previewClip(clipId, trackId, updates) {
128
+ if (this.interactionPrev === null)
129
+ this.interactionPrev = this.project;
130
+ const next = produce(this.project, (draft) => updateClip(draft, clipId, trackId, updates));
131
+ if (next === this.project)
132
+ return;
133
+ this.project = next;
134
+ this.emit('change', this.project);
135
+ this.emit('clip:updated', this.findClip(clipId).clip);
136
+ }
137
+ commitInteraction(description = 'Edit clip') {
138
+ const prev = this.interactionPrev;
139
+ if (prev === null)
140
+ return;
141
+ this.interactionPrev = null;
142
+ const next = this.project;
143
+ if (next === prev)
144
+ return;
145
+ this.undoStack.push({ prev, next, description });
146
+ if (this.undoStack.length > this.maxHistorySize) {
147
+ this.undoStack.shift();
148
+ }
149
+ this.redoStack = [];
150
+ this.emit('history:change', {
151
+ canUndo: this.canUndo(),
152
+ canRedo: this.canRedo(),
153
+ });
154
+ }
155
+ cancelInteraction() {
156
+ const prev = this.interactionPrev;
157
+ if (prev === null)
158
+ return;
159
+ this.interactionPrev = null;
160
+ if (this.project !== prev) {
161
+ this.project = prev;
162
+ this.emit('change', this.project);
163
+ }
164
+ }
165
+ moveClip(clipId, fromTrackId, toTrackId, startFrame) {
166
+ const fromClips = this.project.clips[fromTrackId];
167
+ if (!fromClips)
168
+ return;
169
+ const clip = fromClips.find((c) => c.id === clipId);
170
+ if (!clip)
171
+ return;
172
+ const newStart = toFrame(startFrame);
173
+ const candidate = { startFrame: newStart, durationFrames: clip.durationFrames };
174
+ const destClips = this.project.clips[toTrackId] ?? [];
175
+ if (findOverlaps(destClips, candidate, clipId).length > 0)
176
+ return;
177
+ this.commit((draft) => {
178
+ const fromClipsDraft = draft.clips[fromTrackId];
179
+ if (!fromClipsDraft)
180
+ return;
181
+ const idx = fromClipsDraft.findIndex((c) => c.id === clipId);
182
+ if (idx === -1)
183
+ return;
184
+ const [movedClip] = fromClipsDraft.splice(idx, 1);
185
+ movedClip.startFrame = newStart;
186
+ movedClip.trackId = toTrackId;
187
+ if (!draft.clips[toTrackId])
188
+ draft.clips[toTrackId] = [];
189
+ draft.clips[toTrackId].push(movedClip);
190
+ draft.clips[toTrackId].sort((a, b) => a.startFrame - b.startFrame);
191
+ pruneOrphanedTransitions(draft);
192
+ }, 'Move clip');
193
+ }
194
+ trimClip(clipId, trackId, startFrame, durationFrames) {
195
+ const trackClips = this.project.clips[trackId];
196
+ const existing = trackClips?.find((c) => c.id === clipId);
197
+ if (!existing)
198
+ return;
199
+ const isText = existing.type === 'text';
200
+ const maxDuration = isText ? Infinity : existing.sourceDurationFrames;
201
+ const clampedDuration = Math.min(maxDuration, Math.max(1, toFrame(durationFrames)));
202
+ const rawStart = Math.max(0, toFrame(startFrame));
203
+ const minAllowedStart = isText
204
+ ? 0
205
+ : Math.max(0, existing.startFrame - existing.sourceStartFrame);
206
+ const newStart = Math.max(minAllowedStart, rawStart);
207
+ const candidate = { startFrame: newStart, durationFrames: clampedDuration };
208
+ if (findOverlaps(trackClips, candidate, clipId).length > 0)
209
+ return;
210
+ const startDelta = newStart - existing.startFrame;
211
+ const sourceStartFrame = isText
212
+ ? existing.sourceStartFrame
213
+ : Math.max(0, existing.sourceStartFrame + startDelta);
214
+ this.commit((draft) => {
215
+ updateClip(draft, clipId, trackId, {
216
+ startFrame: newStart,
217
+ durationFrames: clampedDuration,
218
+ sourceStartFrame,
219
+ });
220
+ pruneOrphanedTransitions(draft);
221
+ }, 'Trim clip');
222
+ }
223
+ splitClip(clipId, trackId, atFrame) {
224
+ let result = null;
225
+ this.commit((draft) => {
226
+ result = splitClip(draft, clipId, trackId, atFrame);
227
+ pruneOrphanedTransitions(draft);
228
+ }, 'Split clip');
229
+ if (result) {
230
+ this.emit('clip:split', {
231
+ leftId: result[0],
232
+ rightId: result[1],
233
+ trackId,
234
+ });
235
+ }
236
+ return result;
237
+ }
238
+ cloneClip(clipId, trackId, startFrame) {
239
+ let newId = null;
240
+ this.commit((draft) => {
241
+ newId = cloneClip(draft, clipId, trackId, startFrame);
242
+ }, 'Clone clip');
243
+ return newId;
244
+ }
245
+ addTransition(options) {
246
+ const fromClip = this.project.clips[options.trackId]?.find((c) => c.id === options.fromClipId);
247
+ const toClip = this.project.clips[options.trackId]?.find((c) => c.id === options.toClipId);
248
+ if (!fromClip || !toClip)
249
+ return null;
250
+ const half = Math.max(1, Math.floor(options.durationFrames / 2));
251
+ const offset = options.offsetFrames ?? 0;
252
+ const transition = {
253
+ id: generateId(),
254
+ kind: options.kind,
255
+ fromClipId: options.fromClipId,
256
+ toClipId: options.toClipId,
257
+ trackId: options.trackId,
258
+ startFrame: toClip.startFrame - half + offset,
259
+ durationFrames: half * 2,
260
+ easing: options.easing,
261
+ direction: options.direction,
262
+ };
263
+ this.commit((draft) => {
264
+ draft.transitions.push(transition);
265
+ }, 'Add transition');
266
+ this.emit('transition:added', transition);
267
+ return transition;
268
+ }
269
+ removeTransition(transitionId) {
270
+ this.commit((draft) => {
271
+ const idx = draft.transitions.findIndex((t) => t.id === transitionId);
272
+ if (idx !== -1)
273
+ draft.transitions.splice(idx, 1);
274
+ }, 'Remove transition');
275
+ this.emit('transition:removed', transitionId);
276
+ }
277
+ updateTransition(transitionId, patch) {
278
+ const existing = this.project.transitions.find((t) => t.id === transitionId);
279
+ if (!existing)
280
+ return;
281
+ this.commit((draft) => {
282
+ const t = draft.transitions.find((t) => t.id === transitionId);
283
+ if (!t)
284
+ return;
285
+ if (patch.kind !== undefined)
286
+ t.kind = patch.kind;
287
+ if (patch.easing !== undefined)
288
+ t.easing = patch.easing;
289
+ if (patch.direction !== undefined)
290
+ t.direction = patch.direction;
291
+ if (patch.durationFrames !== undefined || patch.offsetFrames !== undefined) {
292
+ const newDuration = patch.durationFrames ?? t.durationFrames;
293
+ const half = Math.max(1, Math.floor(newDuration / 2));
294
+ t.durationFrames = half * 2;
295
+ const toClipEntry = Object.values(draft.clips)
296
+ .flat()
297
+ .find((c) => c.id === t.toClipId);
298
+ if (toClipEntry) {
299
+ const offset = patch.offsetFrames ?? (t.startFrame - (toClipEntry.startFrame - half));
300
+ t.startFrame = toClipEntry.startFrame - half + offset;
301
+ }
302
+ }
303
+ }, 'Update transition');
304
+ }
305
+ loadProject(project) {
306
+ this.project = project;
307
+ this.undoStack = [];
308
+ this.redoStack = [];
309
+ this.emit('change', this.project);
310
+ this.emit('history:change', { canUndo: false, canRedo: false });
311
+ }
312
+ batch(recipe, description) {
313
+ if (this.batchDepth === 0) {
314
+ this.batchPrev = this.project;
315
+ this.batchDescription = description ?? null;
316
+ }
317
+ this.batchDepth++;
318
+ try {
319
+ recipe();
320
+ }
321
+ catch (err) {
322
+ this.batchDepth--;
323
+ if (this.batchDepth === 0) {
324
+ this.project = this.batchPrev;
325
+ this.batchPrev = null;
326
+ this.batchDescription = null;
327
+ }
328
+ throw err;
329
+ }
330
+ this.batchDepth--;
331
+ if (this.batchDepth > 0)
332
+ return;
333
+ const prev = this.batchPrev;
334
+ const next = this.project;
335
+ const desc = this.batchDescription ?? 'Batch';
336
+ this.batchPrev = null;
337
+ this.batchDescription = null;
338
+ if (next === prev)
339
+ return;
340
+ const entry = { prev, next, description: desc };
341
+ this.undoStack.push(entry);
342
+ if (this.undoStack.length > this.maxHistorySize) {
343
+ this.undoStack.shift();
344
+ }
345
+ this.redoStack = [];
346
+ this.emit('change', this.project);
347
+ this.emit('history:change', {
348
+ canUndo: this.canUndo(),
349
+ canRedo: this.canRedo(),
350
+ });
351
+ }
352
+ undo() {
353
+ const entry = this.undoStack.pop();
354
+ if (!entry)
355
+ return false;
356
+ this.redoStack.push(entry);
357
+ this.project = entry.prev;
358
+ this.emit('change', this.project);
359
+ this.emit('history:change', {
360
+ canUndo: this.canUndo(),
361
+ canRedo: this.canRedo(),
362
+ });
363
+ return true;
364
+ }
365
+ redo() {
366
+ const entry = this.redoStack.pop();
367
+ if (!entry)
368
+ return false;
369
+ this.undoStack.push(entry);
370
+ this.project = entry.next;
371
+ this.emit('change', this.project);
372
+ this.emit('history:change', {
373
+ canUndo: this.canUndo(),
374
+ canRedo: this.canRedo(),
375
+ });
376
+ return true;
377
+ }
378
+ on(event, listener) {
379
+ if (!this.listeners.has(event)) {
380
+ this.listeners.set(event, new Set());
381
+ }
382
+ this.listeners.get(event).add(listener);
383
+ }
384
+ off(event, listener) {
385
+ this.listeners.get(event)?.delete(listener);
386
+ }
387
+ commit(recipe, description) {
388
+ const prev = this.project;
389
+ const next = produce(prev, recipe);
390
+ if (next === prev)
391
+ return;
392
+ this.project = next;
393
+ if (this.batchDepth > 0) {
394
+ if (this.batchDescription === null)
395
+ this.batchDescription = description;
396
+ return;
397
+ }
398
+ const entry = { prev, next, description };
399
+ this.undoStack.push(entry);
400
+ if (this.undoStack.length > this.maxHistorySize) {
401
+ this.undoStack.shift();
402
+ }
403
+ this.redoStack = [];
404
+ this.emit('change', this.project);
405
+ this.emit('history:change', {
406
+ canUndo: this.canUndo(),
407
+ canRedo: this.canRedo(),
408
+ });
409
+ }
410
+ emit(event, payload) {
411
+ this.listeners.get(event)?.forEach((fn) => fn(payload));
412
+ }
413
+ }
@@ -0,0 +1,10 @@
1
+ import type { TimelineEngine } from './editor/TimelineEngine';
2
+ import type { PlaybackEngine } from './playback/PlaybackEngine';
3
+ export interface EditorContextValue {
4
+ engine: TimelineEngine;
5
+ playback: PlaybackEngine;
6
+ }
7
+ export declare const EditorContext: import("react").Context<EditorContextValue | null>;
8
+ export declare function useEditor(): EditorContextValue;
9
+ export declare const useTimelineEngine: () => TimelineEngine;
10
+ export declare const usePlaybackEngine: () => PlaybackEngine;
@@ -0,0 +1,10 @@
1
+ import { createContext, useContext } from 'react';
2
+ export const EditorContext = createContext(null);
3
+ export function useEditor() {
4
+ const ctx = useContext(EditorContext);
5
+ if (!ctx)
6
+ throw new Error('useEditor must be used inside <EditorProvider>');
7
+ return ctx;
8
+ }
9
+ export const useTimelineEngine = () => useEditor().engine;
10
+ export const usePlaybackEngine = () => useEditor().playback;
@@ -0,0 +1,13 @@
1
+ import type { Clip, Transform } from '../types';
2
+ export interface CreateAudioClipOptions {
3
+ trackId: string;
4
+ name?: string;
5
+ startFrame: number;
6
+ durationFrames: number;
7
+ src: string;
8
+ assetId?: string;
9
+ volume?: number;
10
+ opacity?: number;
11
+ transform?: Transform;
12
+ }
13
+ export declare function createAudioClip(options: CreateAudioClipOptions): Clip;
@@ -0,0 +1,4 @@
1
+ import { createClip } from './base';
2
+ export function createAudioClip(options) {
3
+ return createClip({ ...options, type: 'audio', name: options.name ?? 'Audio' });
4
+ }
@@ -0,0 +1,40 @@
1
+ import type { Clip, Transform } from '../types';
2
+ interface BaseCreateOptions {
3
+ trackId: string;
4
+ name?: string;
5
+ startFrame: number;
6
+ durationFrames: number;
7
+ volume?: number;
8
+ opacity?: number;
9
+ transform?: Transform;
10
+ }
11
+ interface CreateVideoOptions extends BaseCreateOptions {
12
+ type: 'video';
13
+ src: string;
14
+ assetId?: string;
15
+ }
16
+ interface CreateAudioOptions extends BaseCreateOptions {
17
+ type: 'audio';
18
+ src: string;
19
+ assetId?: string;
20
+ }
21
+ interface CreateImageOptions extends BaseCreateOptions {
22
+ type: 'image';
23
+ src: string;
24
+ assetId?: string;
25
+ }
26
+ export interface TextClipMetadata {
27
+ content: string;
28
+ fontSize?: number;
29
+ color?: string;
30
+ fontFamily?: string;
31
+ fontWeight?: 'normal' | 'bold';
32
+ textAlign?: 'left' | 'center' | 'right';
33
+ }
34
+ interface CreateTextOptions extends BaseCreateOptions {
35
+ type: 'text';
36
+ text: TextClipMetadata;
37
+ }
38
+ export type CreateClipOptions = CreateVideoOptions | CreateAudioOptions | CreateImageOptions | CreateTextOptions;
39
+ export declare function createClip(options: CreateClipOptions): Clip;
40
+ export {};
@@ -0,0 +1,39 @@
1
+ import { generateId } from '../utils/id';
2
+ import { toFrame } from '../utils/frames';
3
+ export function createClip(options) {
4
+ const durationFrames = Math.max(1, toFrame(options.durationFrames));
5
+ const base = {
6
+ id: generateId(),
7
+ trackId: options.trackId,
8
+ type: options.type,
9
+ name: options.name ?? options.type,
10
+ startFrame: toFrame(options.startFrame),
11
+ durationFrames,
12
+ sourceStartFrame: 0,
13
+ sourceDurationFrames: durationFrames,
14
+ volume: options.volume ?? 1,
15
+ opacity: options.opacity ?? 1,
16
+ locked: false,
17
+ disabled: false,
18
+ ...(options.transform ? { transform: options.transform } : {}),
19
+ };
20
+ if (options.type === 'text') {
21
+ const { text } = options;
22
+ return {
23
+ ...base,
24
+ type: 'text',
25
+ content: text.content,
26
+ ...(text.fontSize !== undefined ? { fontSize: text.fontSize } : {}),
27
+ ...(text.color !== undefined ? { color: text.color } : {}),
28
+ ...(text.fontFamily !== undefined ? { fontFamily: text.fontFamily } : {}),
29
+ ...(text.fontWeight !== undefined ? { fontWeight: text.fontWeight } : {}),
30
+ ...(text.textAlign !== undefined ? { textAlign: text.textAlign } : {}),
31
+ };
32
+ }
33
+ return {
34
+ ...base,
35
+ type: options.type,
36
+ src: options.src,
37
+ assetId: options.assetId,
38
+ };
39
+ }
@@ -0,0 +1,13 @@
1
+ import type { Clip, Transform } from '../types';
2
+ export interface CreateImageClipOptions {
3
+ trackId: string;
4
+ name?: string;
5
+ startFrame: number;
6
+ durationFrames: number;
7
+ src: string;
8
+ assetId?: string;
9
+ volume?: number;
10
+ opacity?: number;
11
+ transform?: Transform;
12
+ }
13
+ export declare function createImageClip(options: CreateImageClipOptions): Clip;
@@ -0,0 +1,4 @@
1
+ import { createClip } from './base';
2
+ export function createImageClip(options) {
3
+ return createClip({ ...options, type: 'image', name: options.name ?? 'Image' });
4
+ }
@@ -0,0 +1,12 @@
1
+ import type { Clip } from '../types';
2
+ import { type TextClipMetadata } from './base';
3
+ export interface CreateTextClipOptions {
4
+ trackId: string;
5
+ name?: string;
6
+ startFrame: number;
7
+ durationFrames: number;
8
+ text: TextClipMetadata;
9
+ volume?: number;
10
+ opacity?: number;
11
+ }
12
+ export declare function createTextClip(options: CreateTextClipOptions): Clip;
@@ -0,0 +1,8 @@
1
+ import { createClip } from './base';
2
+ export function createTextClip(options) {
3
+ return createClip({
4
+ ...options,
5
+ type: 'text',
6
+ name: options.name ?? 'Text',
7
+ });
8
+ }
@@ -0,0 +1,13 @@
1
+ import type { Clip, Transform } from '../types';
2
+ export interface CreateVideoClipOptions {
3
+ trackId: string;
4
+ name?: string;
5
+ startFrame: number;
6
+ durationFrames: number;
7
+ src: string;
8
+ assetId?: string;
9
+ volume?: number;
10
+ opacity?: number;
11
+ transform?: Transform;
12
+ }
13
+ export declare function createVideoClip(options: CreateVideoClipOptions): Clip;
@@ -0,0 +1,4 @@
1
+ import { createClip } from './base';
2
+ export function createVideoClip(options) {
3
+ return createClip({ ...options, type: 'video', name: options.name ?? 'Video' });
4
+ }
@@ -0,0 +1 @@
1
+ export {};