@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,3 @@
1
+ export { splitClipAtPlayhead } from './splitClipAtPlayhead';
2
+ export type { SplitAtPlayheadData } from './splitClipAtPlayhead';
3
+ export type { ActionResult, ActionFailureReason } from './types';
@@ -0,0 +1 @@
1
+ export { splitClipAtPlayhead } from './splitClipAtPlayhead';
@@ -0,0 +1,8 @@
1
+ import type { TimelineEngine } from '../editor/TimelineEngine';
2
+ import type { ActionResult } from './types';
3
+ export interface SplitAtPlayheadData {
4
+ leftId: string;
5
+ rightId: string;
6
+ trackId: string;
7
+ }
8
+ export declare function splitClipAtPlayhead(engine: TimelineEngine): ActionResult<SplitAtPlayheadData>;
@@ -0,0 +1,34 @@
1
+ import { useSelectionStore } from '../stores/selection.store';
2
+ import { usePlaybackStore } from '../stores/playback.store';
3
+ export function splitClipAtPlayhead(engine) {
4
+ const selectedIds = useSelectionStore.getState().selectedClipIds;
5
+ const isPlaying = usePlaybackStore.getState().isPlaying;
6
+ if (isPlaying)
7
+ return { ok: false, reason: 'cannot-split-while-playing' };
8
+ if (selectedIds.size === 0)
9
+ return { ok: false, reason: 'no-selection' };
10
+ if (selectedIds.size > 1)
11
+ return { ok: false, reason: 'multi-selection' };
12
+ const [clipId] = selectedIds;
13
+ if (!clipId)
14
+ return { ok: false, reason: 'no-selection' };
15
+ const found = engine.findClip(clipId);
16
+ if (!found)
17
+ return { ok: false, reason: 'clip-not-found' };
18
+ const { clip, trackId } = found;
19
+ const frame = usePlaybackStore.getState().currentFrame;
20
+ if (frame <= clip.startFrame ||
21
+ frame >= clip.startFrame + clip.durationFrames) {
22
+ return { ok: false, reason: 'playhead-outside-clip' };
23
+ }
24
+ let result = null;
25
+ engine.batch(() => {
26
+ result = engine.splitClip(clip.id, trackId, frame);
27
+ }, 'Split at playhead');
28
+ if (!result)
29
+ return { ok: false, reason: 'engine-rejected' };
30
+ return {
31
+ ok: true,
32
+ data: { leftId: result[0], rightId: result[1], trackId },
33
+ };
34
+ }
@@ -0,0 +1,8 @@
1
+ export type ActionResult<T = void> = {
2
+ ok: true;
3
+ data: T;
4
+ } | {
5
+ ok: false;
6
+ reason: ActionFailureReason;
7
+ };
8
+ export type ActionFailureReason = 'no-selection' | 'multi-selection' | 'clip-not-found' | 'playhead-outside-clip' | 'engine-rejected' | 'cannot-split-while-playing';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import type { MediaAsset } from './types';
2
+ export interface ImportFilesOptions {
3
+ fallbackFps?: number;
4
+ thumbnailMaxDim?: number;
5
+ }
6
+ export interface SkippedImport {
7
+ file: File;
8
+ reason: 'duplicate' | 'unsupported';
9
+ existingAssetId?: string;
10
+ }
11
+ export interface ImportFilesResult {
12
+ imported: MediaAsset[];
13
+ skipped: SkippedImport[];
14
+ }
15
+ interface ProbedMetadata {
16
+ durationSec: number;
17
+ width?: number;
18
+ height?: number;
19
+ hasAudio?: boolean;
20
+ }
21
+ export declare function probeVideo(src: string): Promise<ProbedMetadata>;
22
+ export declare function probeAudio(src: string): Promise<ProbedMetadata>;
23
+ export declare function probeImage(src: string): Promise<ProbedMetadata>;
24
+ export declare function makeVideoThumbnail(src: string, maxDim: number): Promise<string>;
25
+ export declare function makeVideoThumbnailStrip(src: string, count: number, maxDim: number): Promise<string[]>;
26
+ export declare function makeImageThumbnail(src: string, maxDim: number): Promise<string>;
27
+ export declare function computeWaveform(src: string): Promise<Float32Array | null>;
28
+ export declare function importFiles(files: File[], opts?: ImportFilesOptions): Promise<ImportFilesResult>;
29
+ export {};
@@ -0,0 +1,351 @@
1
+ import { generateId } from '../utils/id';
2
+ import { useMediaLibraryStore } from './store';
3
+ const DEFAULT_THUMBNAIL_MAX_DIM = 240;
4
+ const THUMBNAIL_STRIP_COUNT = 4;
5
+ const THUMBNAIL_STRIP_MAX_DIM = 160;
6
+ const WAVEFORM_PEAK_COUNT = 256;
7
+ function detectHasAudio(el) {
8
+ const probe = el;
9
+ if (probe.audioTracks)
10
+ return probe.audioTracks.length > 0;
11
+ if (typeof probe.mozHasAudio === 'boolean')
12
+ return probe.mozHasAudio;
13
+ if (typeof probe.webkitAudioDecodedByteCount === 'number') {
14
+ return probe.webkitAudioDecodedByteCount > 0;
15
+ }
16
+ return false;
17
+ }
18
+ function dedupeKey(file) {
19
+ return `${file.name}|${file.size}|${file.lastModified}`;
20
+ }
21
+ function inferKind(mimeType) {
22
+ if (mimeType.startsWith('video/'))
23
+ return 'video';
24
+ if (mimeType.startsWith('audio/'))
25
+ return 'audio';
26
+ if (mimeType.startsWith('image/'))
27
+ return 'image';
28
+ return null;
29
+ }
30
+ function loadMediaElement(tag, src, onReady) {
31
+ return new Promise((resolve, reject) => {
32
+ const el = document.createElement(tag);
33
+ el.preload = 'metadata';
34
+ el.muted = true;
35
+ const cleanup = () => {
36
+ el.removeEventListener('loadedmetadata', onLoaded);
37
+ el.removeEventListener('error', onError);
38
+ };
39
+ const onLoaded = () => {
40
+ cleanup();
41
+ onReady(el);
42
+ resolve(el);
43
+ };
44
+ const onError = () => {
45
+ cleanup();
46
+ reject(new Error(`Failed to load ${tag} metadata`));
47
+ };
48
+ el.addEventListener('loadedmetadata', onLoaded);
49
+ el.addEventListener('error', onError);
50
+ el.src = src;
51
+ });
52
+ }
53
+ export async function probeVideo(src) {
54
+ const el = await loadMediaElement('video', src, () => { });
55
+ return {
56
+ durationSec: el.duration,
57
+ width: el.videoWidth,
58
+ height: el.videoHeight,
59
+ hasAudio: detectHasAudio(el),
60
+ };
61
+ }
62
+ export async function probeAudio(src) {
63
+ const el = await loadMediaElement('audio', src, () => { });
64
+ return {
65
+ durationSec: el.duration,
66
+ };
67
+ }
68
+ export async function probeImage(src) {
69
+ return new Promise((resolve, reject) => {
70
+ const img = document.createElement('img');
71
+ const cleanup = () => {
72
+ img.onload = null;
73
+ img.onerror = null;
74
+ };
75
+ img.onload = () => {
76
+ cleanup();
77
+ resolve({
78
+ durationSec: 0,
79
+ width: img.naturalWidth,
80
+ height: img.naturalHeight,
81
+ });
82
+ };
83
+ img.onerror = () => {
84
+ cleanup();
85
+ reject(new Error('Failed to load image metadata'));
86
+ };
87
+ img.src = src;
88
+ });
89
+ }
90
+ async function probeMetadata(kind, src) {
91
+ switch (kind) {
92
+ case 'video':
93
+ return probeVideo(src);
94
+ case 'audio':
95
+ return probeAudio(src);
96
+ case 'image':
97
+ return probeImage(src);
98
+ }
99
+ }
100
+ function scaleToFit(width, height, maxDim) {
101
+ if (width <= maxDim && height <= maxDim) {
102
+ return { width, height };
103
+ }
104
+ const scale = maxDim / Math.max(width, height);
105
+ return {
106
+ width: Math.round(width * scale),
107
+ height: Math.round(height * scale),
108
+ };
109
+ }
110
+ function drawToThumbnail(source, sourceWidth, sourceHeight, maxDim) {
111
+ const { width, height } = scaleToFit(sourceWidth, sourceHeight, maxDim);
112
+ const canvas = document.createElement('canvas');
113
+ canvas.width = width;
114
+ canvas.height = height;
115
+ const ctx = canvas.getContext('2d');
116
+ if (!ctx) {
117
+ throw new Error('Failed to acquire 2D canvas context');
118
+ }
119
+ ctx.drawImage(source, 0, 0, width, height);
120
+ return canvas.toDataURL('image/jpeg', 0.7);
121
+ }
122
+ export async function makeVideoThumbnail(src, maxDim) {
123
+ const el = await loadMediaElement('video', src, (video) => {
124
+ video.currentTime = 0;
125
+ });
126
+ return new Promise((resolve, reject) => {
127
+ const cleanup = () => {
128
+ el.removeEventListener('seeked', onSeeked);
129
+ el.removeEventListener('error', onError);
130
+ };
131
+ const onSeeked = () => {
132
+ cleanup();
133
+ try {
134
+ resolve(drawToThumbnail(el, el.videoWidth, el.videoHeight, maxDim));
135
+ }
136
+ catch (err) {
137
+ reject(err);
138
+ }
139
+ };
140
+ const onError = () => {
141
+ cleanup();
142
+ reject(new Error('Failed to seek video for thumbnail'));
143
+ };
144
+ if (el.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
145
+ onSeeked();
146
+ return;
147
+ }
148
+ el.addEventListener('seeked', onSeeked);
149
+ el.addEventListener('error', onError);
150
+ });
151
+ }
152
+ export async function makeVideoThumbnailStrip(src, count, maxDim) {
153
+ const el = await loadMediaElement('video', src, () => { });
154
+ const duration = Number.isFinite(el.duration) ? el.duration : 0;
155
+ const seekTo = (time) => new Promise((resolve, reject) => {
156
+ const cleanup = () => {
157
+ el.removeEventListener('seeked', onSeeked);
158
+ el.removeEventListener('error', onError);
159
+ };
160
+ const onSeeked = () => {
161
+ cleanup();
162
+ try {
163
+ resolve(drawToThumbnail(el, el.videoWidth, el.videoHeight, maxDim));
164
+ }
165
+ catch (err) {
166
+ reject(err);
167
+ }
168
+ };
169
+ const onError = () => {
170
+ cleanup();
171
+ reject(new Error('Failed to seek video for thumbnail strip'));
172
+ };
173
+ el.addEventListener('seeked', onSeeked);
174
+ el.addEventListener('error', onError);
175
+ el.currentTime = time;
176
+ });
177
+ const frames = [];
178
+ for (let i = 0; i < count; i++) {
179
+ const time = duration > 0 ? ((i + 0.5) / count) * duration : 0;
180
+ frames.push(await seekTo(time));
181
+ }
182
+ return frames;
183
+ }
184
+ export async function makeImageThumbnail(src, maxDim) {
185
+ return new Promise((resolve, reject) => {
186
+ const img = document.createElement('img');
187
+ const cleanup = () => {
188
+ img.onload = null;
189
+ img.onerror = null;
190
+ };
191
+ img.onload = () => {
192
+ cleanup();
193
+ try {
194
+ resolve(drawToThumbnail(img, img.naturalWidth, img.naturalHeight, maxDim));
195
+ }
196
+ catch (err) {
197
+ reject(err);
198
+ }
199
+ };
200
+ img.onerror = () => {
201
+ cleanup();
202
+ reject(new Error('Failed to load image for thumbnail'));
203
+ };
204
+ img.src = src;
205
+ });
206
+ }
207
+ let sharedAudioContext = null;
208
+ function getAudioContext() {
209
+ if (!sharedAudioContext) {
210
+ const Ctor = window.AudioContext ??
211
+ window
212
+ .webkitAudioContext;
213
+ sharedAudioContext = new Ctor();
214
+ }
215
+ return sharedAudioContext;
216
+ }
217
+ export async function computeWaveform(src) {
218
+ const res = await fetch(src);
219
+ const buffer = await res.arrayBuffer();
220
+ const audioBuffer = await getAudioContext().decodeAudioData(buffer);
221
+ if (audioBuffer.length === 0)
222
+ return null;
223
+ const channel = audioBuffer.getChannelData(0);
224
+ const peaks = new Float32Array(WAVEFORM_PEAK_COUNT);
225
+ const bucketSize = Math.max(1, Math.floor(channel.length / WAVEFORM_PEAK_COUNT));
226
+ let globalMax = 0;
227
+ for (let i = 0; i < WAVEFORM_PEAK_COUNT; i++) {
228
+ const start = i * bucketSize;
229
+ const end = Math.min(start + bucketSize, channel.length);
230
+ let max = 0;
231
+ for (let j = start; j < end; j++) {
232
+ const v = Math.abs(channel[j]);
233
+ if (v > max)
234
+ max = v;
235
+ }
236
+ peaks[i] = max;
237
+ if (max > globalMax)
238
+ globalMax = max;
239
+ }
240
+ if (globalMax > 0) {
241
+ for (let i = 0; i < peaks.length; i++)
242
+ peaks[i] /= globalMax;
243
+ }
244
+ return peaks;
245
+ }
246
+ function scheduleThumbnail(asset, maxDim) {
247
+ if (asset.kind === 'video') {
248
+ void makeVideoThumbnailStrip(asset.src, THUMBNAIL_STRIP_COUNT, THUMBNAIL_STRIP_MAX_DIM)
249
+ .then((strip) => {
250
+ if (strip.length === 0)
251
+ return;
252
+ useMediaLibraryStore.getState().updateAsset(asset.id, {
253
+ thumbnailStrip: strip,
254
+ thumbnailUrl: strip[Math.floor(strip.length / 2)],
255
+ });
256
+ })
257
+ .catch((err) => {
258
+ console.warn(`[importFiles] Thumbnail strip failed for "${asset.name}":`, err);
259
+ });
260
+ return;
261
+ }
262
+ if (asset.kind === 'image') {
263
+ void makeImageThumbnail(asset.src, maxDim)
264
+ .then((thumbnailUrl) => {
265
+ useMediaLibraryStore.getState().updateAsset(asset.id, { thumbnailUrl });
266
+ })
267
+ .catch((err) => {
268
+ console.warn(`[importFiles] Thumbnail failed for "${asset.name}":`, err);
269
+ });
270
+ }
271
+ }
272
+ function scheduleAudioAnalysis(asset) {
273
+ if (asset.kind !== 'audio' && asset.kind !== 'video')
274
+ return;
275
+ void computeWaveform(asset.src)
276
+ .then((waveform) => {
277
+ if (!waveform) {
278
+ if (asset.kind === 'video') {
279
+ useMediaLibraryStore.getState().updateAsset(asset.id, { hasAudio: false });
280
+ }
281
+ return;
282
+ }
283
+ useMediaLibraryStore.getState().updateAsset(asset.id, {
284
+ waveform,
285
+ ...(asset.kind === 'video' ? { hasAudio: true } : {}),
286
+ });
287
+ })
288
+ .catch(() => {
289
+ if (asset.kind === 'video') {
290
+ useMediaLibraryStore.getState().updateAsset(asset.id, { hasAudio: false });
291
+ }
292
+ });
293
+ }
294
+ async function importSingleFile(file, kind, thumbnailMaxDim) {
295
+ const src = URL.createObjectURL(file);
296
+ const metadata = await probeMetadata(kind, src);
297
+ const asset = {
298
+ id: generateId(),
299
+ kind,
300
+ name: file.name,
301
+ src,
302
+ durationSec: metadata.durationSec,
303
+ width: metadata.width,
304
+ height: metadata.height,
305
+ ...(kind === 'video' ? { hasAudio: metadata.hasAudio ?? false } : {}),
306
+ byteSize: file.size,
307
+ lastModified: file.lastModified,
308
+ addedAt: Date.now(),
309
+ };
310
+ useMediaLibraryStore.getState().addAsset(asset);
311
+ scheduleThumbnail(asset, thumbnailMaxDim);
312
+ scheduleAudioAnalysis(asset);
313
+ return asset;
314
+ }
315
+ function partitionFiles(files) {
316
+ const storeAssets = useMediaLibraryStore.getState().assets;
317
+ const existingByKey = new Map();
318
+ for (const asset of Object.values(storeAssets)) {
319
+ existingByKey.set(dedupeKey({ name: asset.name, size: asset.byteSize, lastModified: asset.lastModified }), asset.id);
320
+ }
321
+ const batchKeys = new Set();
322
+ const toImport = [];
323
+ const skipped = [];
324
+ for (const file of files) {
325
+ const kind = inferKind(file.type);
326
+ if (!kind) {
327
+ console.warn(`[importFiles] Skipping unsupported file type: "${file.type}" (${file.name})`);
328
+ skipped.push({ file, reason: 'unsupported' });
329
+ continue;
330
+ }
331
+ const key = dedupeKey(file);
332
+ const existingAssetId = existingByKey.get(key);
333
+ if (existingAssetId) {
334
+ skipped.push({ file, reason: 'duplicate', existingAssetId });
335
+ continue;
336
+ }
337
+ if (batchKeys.has(key)) {
338
+ skipped.push({ file, reason: 'duplicate' });
339
+ continue;
340
+ }
341
+ batchKeys.add(key);
342
+ toImport.push({ file, kind });
343
+ }
344
+ return { toImport, skipped };
345
+ }
346
+ export async function importFiles(files, opts) {
347
+ const thumbnailMaxDim = opts?.thumbnailMaxDim ?? DEFAULT_THUMBNAIL_MAX_DIM;
348
+ const { toImport, skipped } = partitionFiles(files);
349
+ const imported = await Promise.all(toImport.map(({ file, kind }) => importSingleFile(file, kind, thumbnailMaxDim)));
350
+ return { imported, skipped };
351
+ }
@@ -0,0 +1,10 @@
1
+ import type { MediaAsset } from './types';
2
+ export type { MediaAsset, MediaKind, DragMediaPayload } from './types';
3
+ export { MEDIA_DRAG_MIME } from './types';
4
+ export { useMediaLibraryStore } from './store';
5
+ export { importFiles } from './importFiles';
6
+ export type { ImportFilesOptions, ImportFilesResult, SkippedImport } from './importFiles';
7
+ export declare function useMediaLibrary(): {
8
+ assets: MediaAsset[];
9
+ getAsset: (id: string) => MediaAsset | undefined;
10
+ };
@@ -0,0 +1,13 @@
1
+ import { useMediaLibraryStore } from './store';
2
+ export { MEDIA_DRAG_MIME } from './types';
3
+ export { useMediaLibraryStore } from './store';
4
+ export { importFiles } from './importFiles';
5
+ export function useMediaLibrary() {
6
+ const order = useMediaLibraryStore((s) => s.order);
7
+ const assets = useMediaLibraryStore((s) => s.assets);
8
+ const getAsset = useMediaLibraryStore((s) => s.getAsset);
9
+ return {
10
+ assets: order.map((id) => assets[id]).filter(Boolean),
11
+ getAsset,
12
+ };
13
+ }
@@ -0,0 +1,13 @@
1
+ import type { MediaAsset } from './types';
2
+ interface MediaLibraryState {
3
+ assets: Record<string, MediaAsset>;
4
+ order: string[];
5
+ }
6
+ interface MediaLibraryActions {
7
+ addAsset: (asset: MediaAsset) => void;
8
+ removeAsset: (id: string) => void;
9
+ updateAsset: (id: string, patch: Partial<MediaAsset>) => void;
10
+ getAsset: (id: string) => MediaAsset | undefined;
11
+ }
12
+ export declare const useMediaLibraryStore: import("zustand").UseBoundStore<import("zustand").StoreApi<MediaLibraryState & MediaLibraryActions>>;
13
+ export {};
@@ -0,0 +1,20 @@
1
+ import { create } from 'zustand';
2
+ export const useMediaLibraryStore = create((set, get) => ({
3
+ assets: {},
4
+ order: [],
5
+ addAsset: (asset) => set((s) => ({
6
+ assets: { ...s.assets, [asset.id]: asset },
7
+ order: s.order.includes(asset.id) ? s.order : [...s.order, asset.id],
8
+ })),
9
+ removeAsset: (id) => set((s) => {
10
+ const { [id]: _removed, ...rest } = s.assets;
11
+ return { assets: rest, order: s.order.filter((x) => x !== id) };
12
+ }),
13
+ updateAsset: (id, patch) => set((s) => {
14
+ const existing = s.assets[id];
15
+ if (!existing)
16
+ return s;
17
+ return { assets: { ...s.assets, [id]: { ...existing, ...patch } } };
18
+ }),
19
+ getAsset: (id) => get().assets[id],
20
+ }));
@@ -0,0 +1,23 @@
1
+ export type MediaKind = 'video' | 'audio' | 'image';
2
+ export interface MediaAsset {
3
+ id: string;
4
+ kind: MediaKind;
5
+ name: string;
6
+ src: string;
7
+ durationSec: number;
8
+ width?: number;
9
+ height?: number;
10
+ sourceFps?: number;
11
+ hasAudio?: boolean;
12
+ thumbnailUrl?: string;
13
+ thumbnailStrip?: string[];
14
+ waveform?: Float32Array;
15
+ byteSize: number;
16
+ lastModified: number;
17
+ addedAt: number;
18
+ }
19
+ export declare const MEDIA_DRAG_MIME = "application/x-elah-media";
20
+ export interface DragMediaPayload {
21
+ kind: 'media-asset';
22
+ assetId: string;
23
+ }
@@ -0,0 +1 @@
1
+ export const MEDIA_DRAG_MIME = 'application/x-elah-media';
@@ -0,0 +1,20 @@
1
+ export type TraceChannel = 'SET_PLAYHEAD' | 'PLAYHEAD' | 'SEEK_GATE' | 'FEED' | 'DECODE' | 'CACHE_PUT' | 'CACHE_GET' | 'UPLOAD' | 'DRAW' | 'EXPORT' | 'EXPORT_ASSETS' | 'EXPORT_AUDIO' | 'EXPORT_MUX' | 'EXPORT_FRAMES';
2
+ export declare function trace(channel: TraceChannel, ...args: unknown[]): void;
3
+ export declare function traceEnabled(channel: TraceChannel): boolean;
4
+ export declare function getEnabledChannels(): TraceChannel[];
5
+ export declare function enableChannels(channels: TraceChannel[]): void;
6
+ interface TraceConsoleApi {
7
+ on(...channels: TraceChannel[]): TraceChannel[];
8
+ off(...channels: TraceChannel[]): TraceChannel[];
9
+ all(): TraceChannel[];
10
+ none(): TraceChannel[];
11
+ status(): TraceChannel[];
12
+ channels: TraceChannel[];
13
+ }
14
+ declare global {
15
+ interface Window {
16
+ __trace?: TraceConsoleApi;
17
+ }
18
+ }
19
+ export declare function installTraceGlobal(): void;
20
+ export {};
@@ -0,0 +1,98 @@
1
+ const ALL_CHANNELS = [
2
+ 'SET_PLAYHEAD',
3
+ 'PLAYHEAD',
4
+ 'SEEK_GATE',
5
+ 'FEED',
6
+ 'DECODE',
7
+ 'CACHE_PUT',
8
+ 'CACHE_GET',
9
+ 'UPLOAD',
10
+ 'DRAW',
11
+ 'EXPORT',
12
+ 'EXPORT_ASSETS',
13
+ 'EXPORT_AUDIO',
14
+ 'EXPORT_MUX',
15
+ 'EXPORT_FRAMES',
16
+ ];
17
+ const STORAGE_KEY = 'myeditor-trace-channels';
18
+ const enabled = new Set();
19
+ function persist() {
20
+ if (typeof localStorage === 'undefined')
21
+ return;
22
+ try {
23
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...enabled]));
24
+ }
25
+ catch {
26
+ }
27
+ }
28
+ function restore() {
29
+ if (typeof localStorage === 'undefined')
30
+ return;
31
+ try {
32
+ const raw = localStorage.getItem(STORAGE_KEY);
33
+ if (!raw)
34
+ return;
35
+ for (const ch of JSON.parse(raw)) {
36
+ if (ALL_CHANNELS.includes(ch))
37
+ enabled.add(ch);
38
+ }
39
+ }
40
+ catch {
41
+ }
42
+ }
43
+ export function trace(channel, ...args) {
44
+ if (!enabled.has(channel))
45
+ return;
46
+ console.log(`[${channel}]`, ...args);
47
+ }
48
+ export function traceEnabled(channel) {
49
+ return enabled.has(channel);
50
+ }
51
+ export function getEnabledChannels() {
52
+ return [...enabled];
53
+ }
54
+ export function enableChannels(channels) {
55
+ for (const ch of channels) {
56
+ if (ALL_CHANNELS.includes(ch))
57
+ enabled.add(ch);
58
+ }
59
+ }
60
+ export function installTraceGlobal() {
61
+ if (typeof window === 'undefined')
62
+ return;
63
+ restore();
64
+ const api = {
65
+ on(...channels) {
66
+ for (const ch of channels)
67
+ enabled.add(ch);
68
+ persist();
69
+ return [...enabled];
70
+ },
71
+ off(...channels) {
72
+ for (const ch of channels)
73
+ enabled.delete(ch);
74
+ persist();
75
+ return [...enabled];
76
+ },
77
+ all() {
78
+ for (const ch of ALL_CHANNELS)
79
+ enabled.add(ch);
80
+ persist();
81
+ return [...enabled];
82
+ },
83
+ none() {
84
+ enabled.clear();
85
+ persist();
86
+ return [];
87
+ },
88
+ status() {
89
+ return [...enabled];
90
+ },
91
+ channels: ALL_CHANNELS,
92
+ };
93
+ Object.defineProperty(window, '__trace', {
94
+ configurable: true,
95
+ enumerable: true,
96
+ value: api,
97
+ });
98
+ }