@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,371 @@
1
+ import * as mb from 'mediabunny';
2
+ import { trace, traceEnabled, enableChannels } from '../debug/trace';
3
+ const t0 = performance.now();
4
+ const STAGE_CHANNEL = {
5
+ worker: 'EXPORT',
6
+ run: 'EXPORT',
7
+ 'assets:video': 'EXPORT_ASSETS',
8
+ 'assets:image': 'EXPORT_ASSETS',
9
+ audio: 'EXPORT_AUDIO',
10
+ mediabunny: 'EXPORT_MUX',
11
+ frames: 'EXPORT_FRAMES',
12
+ 'render:frame0': 'EXPORT_FRAMES',
13
+ };
14
+ function xlog(stage, msg, extra) {
15
+ const channel = STAGE_CHANNEL[stage] ?? 'EXPORT';
16
+ if (!traceEnabled(channel))
17
+ return;
18
+ const elapsed = ((performance.now() - t0) / 1000).toFixed(3);
19
+ const suffix = extra ? ' — ' + Object.entries(extra).map(([k, v]) => `${k}=${v}`).join(', ') : '';
20
+ trace(channel, `[${stage}] +${elapsed}s ${msg}${suffix}`);
21
+ }
22
+ async function timed(stage, label, fn) {
23
+ xlog(stage, `${label} ...`);
24
+ const start = performance.now();
25
+ const result = await fn();
26
+ xlog(stage, `${label} done`, { ms: (performance.now() - start).toFixed(1) });
27
+ return result;
28
+ }
29
+ function fmtBytes(n) {
30
+ if (n > 1000000)
31
+ return (n / 1000000).toFixed(2) + ' MB';
32
+ if (n > 1000)
33
+ return (n / 1000).toFixed(1) + ' KB';
34
+ return n + ' B';
35
+ }
36
+ import { resolveTimeline } from '../resolver/resolveTimeline';
37
+ import { getTotalFrames } from '../utils/frames';
38
+ import { resolveDrawRect } from '../renderer/gpu/layers/drawRect';
39
+ import { computeTextLayout } from '../renderer/gpu/layers/textLayout';
40
+ self.onmessage = async (e) => {
41
+ if (e.data?.type !== 'start')
42
+ return;
43
+ const { project, options, audio, trace: traceChannels } = e.data;
44
+ if (traceChannels)
45
+ enableChannels(traceChannels);
46
+ xlog('worker', 'message received — starting export');
47
+ try {
48
+ const buffer = await runExport(project, options, audio);
49
+ xlog('worker', 'posting buffer to main thread', { size: fmtBytes(buffer.byteLength) });
50
+ const msg = { type: 'done', buffer };
51
+ self.postMessage(msg, [buffer]);
52
+ }
53
+ catch (err) {
54
+ xlog('worker', `export failed: ${String(err)}`);
55
+ const msg = { type: 'error', message: String(err) };
56
+ self.postMessage(msg);
57
+ }
58
+ };
59
+ async function runExport(project, options, audio) {
60
+ const { width, height } = project.stage;
61
+ const fps = project.fps;
62
+ const totalFrames = getTotalFrames(project.clips);
63
+ xlog('run', 'starting', {
64
+ stage: `${width}x${height}`,
65
+ fps,
66
+ totalFrames,
67
+ durationSec: (totalFrames / fps).toFixed(2),
68
+ videoCodec: options.videoCodec ?? 'avc',
69
+ videoBitrate: options.videoBitrate ?? 8000000,
70
+ audioCodec: options.audioCodec ?? 'aac',
71
+ audioBitrate: options.audioBitrate ?? 128000,
72
+ });
73
+ if (totalFrames === 0)
74
+ throw new Error('ExportWorker: project has no clips');
75
+ const allClips = Object.values(project.clips).flat();
76
+ xlog('run', `clip inventory`, {
77
+ total: allClips.length,
78
+ video: allClips.filter(c => c.type === 'video').length,
79
+ image: allClips.filter(c => c.type === 'image').length,
80
+ audio: allClips.filter(c => c.type === 'audio').length,
81
+ text: allClips.filter(c => c.type === 'text').length,
82
+ });
83
+ const videoSrcs = [...new Set(allClips.filter(c => c.type === 'video' && c.src).map(c => c.src))];
84
+ xlog('assets:video', `opening ${videoSrcs.length} CanvasSink(s)`);
85
+ const videoSinks = new Map();
86
+ for (let i = 0; i < videoSrcs.length; i++) {
87
+ const src = videoSrcs[i];
88
+ xlog('assets:video', `[${i + 1}/${videoSrcs.length}] fetching "${src.slice(-40)}"`);
89
+ const fetchStart = performance.now();
90
+ const blob = await fetch(src).then(r => r.blob());
91
+ xlog('assets:video', `[${i + 1}/${videoSrcs.length}] fetched`, { size: fmtBytes(blob.size), ms: (performance.now() - fetchStart).toFixed(1) });
92
+ const input = new mb.Input({ formats: mb.ALL_FORMATS, source: new mb.BlobSource(blob) });
93
+ const track = await input.getPrimaryVideoTrack();
94
+ if (track) {
95
+ videoSinks.set(src, new mb.CanvasSink(track));
96
+ xlog('assets:video', `[${i + 1}/${videoSrcs.length}] CanvasSink ready`);
97
+ }
98
+ else {
99
+ xlog('assets:video', `[${i + 1}/${videoSrcs.length}] WARNING: no primary video track found`);
100
+ }
101
+ }
102
+ const imageSrcs = [...new Set(allClips.filter(c => c.type === 'image' && c.src).map(c => c.src))];
103
+ xlog('assets:image', `loading ${imageSrcs.length} ImageBitmap(s)`);
104
+ const imageBitmaps = new Map();
105
+ for (let i = 0; i < imageSrcs.length; i++) {
106
+ const src = imageSrcs[i];
107
+ xlog('assets:image', `[${i + 1}/${imageSrcs.length}] fetching "${src.slice(-40)}"`);
108
+ const fetchStart = performance.now();
109
+ const blob = await fetch(src).then(r => r.blob());
110
+ const bitmap = await createImageBitmap(blob);
111
+ imageBitmaps.set(src, bitmap);
112
+ xlog('assets:image', `[${i + 1}/${imageSrcs.length}] ready`, {
113
+ size: fmtBytes(blob.size),
114
+ bitmapW: bitmap.width,
115
+ bitmapH: bitmap.height,
116
+ ms: (performance.now() - fetchStart).toFixed(1),
117
+ });
118
+ }
119
+ if (audio) {
120
+ xlog('audio', `received mixed PCM`, {
121
+ channels: audio.numberOfChannels,
122
+ frames: audio.length,
123
+ sampleRate: audio.sampleRate,
124
+ durationSec: (audio.length / audio.sampleRate).toFixed(3),
125
+ });
126
+ }
127
+ else {
128
+ xlog('audio', 'no mixed audio received — exporting video-only');
129
+ }
130
+ xlog('mediabunny', `creating OffscreenCanvas ${width}x${height}`);
131
+ const outputCanvas = new OffscreenCanvas(width, height);
132
+ const ctx2d = outputCanvas.getContext('2d');
133
+ const videoCodec = options.videoCodec ?? 'avc';
134
+ const videoBitrate = options.videoBitrate ?? 8000000;
135
+ xlog('mediabunny', `creating CanvasSource`, { codec: videoCodec, bitrate: videoBitrate });
136
+ const canvasSource = new mb.CanvasSource(outputCanvas, {
137
+ codec: videoCodec,
138
+ bitrate: videoBitrate,
139
+ });
140
+ xlog('mediabunny', 'creating BufferTarget + Mp4Output');
141
+ const bufferTarget = new mb.BufferTarget();
142
+ const output = new mb.Output({ format: new mb.Mp4OutputFormat(), target: bufferTarget });
143
+ output.addVideoTrack(canvasSource);
144
+ xlog('mediabunny', 'video track added to output');
145
+ let audioSource = null;
146
+ if (audio) {
147
+ const audioCodec = options.audioCodec ?? 'aac';
148
+ const audioBitrate = options.audioBitrate ?? 128000;
149
+ xlog('mediabunny', `creating AudioSampleSource`, { codec: audioCodec, bitrate: audioBitrate });
150
+ audioSource = new mb.AudioSampleSource({
151
+ codec: audioCodec,
152
+ bitrate: audioBitrate,
153
+ });
154
+ output.addAudioTrack(audioSource);
155
+ xlog('mediabunny', 'audio track added to output');
156
+ }
157
+ await timed('mediabunny', 'output.start()', () => output.start());
158
+ if (audio && audioSource) {
159
+ await timed('mediabunny', 'audioSource.add() — encoding mixed PCM', () => addAudioMix(audioSource, audio));
160
+ }
161
+ xlog('frames', `starting frame loop`, { totalFrames, fps });
162
+ const loopStart = performance.now();
163
+ const logEvery = Math.max(1, Math.floor(totalFrames / 10));
164
+ let lastFrameTime = loopStart;
165
+ const transitionSnapshots = new Map();
166
+ for (let frame = 0; frame < totalFrames; frame++) {
167
+ if (frame === 0 || frame % logEvery === 0 || frame === totalFrames - 1) {
168
+ const now = performance.now();
169
+ const elapsed = now - loopStart;
170
+ const avgMsPerFrame = frame > 0 ? elapsed / frame : 0;
171
+ const pct = Math.round((frame / totalFrames) * 100);
172
+ xlog('frames', `frame ${frame}/${totalFrames} (${pct}%)`, {
173
+ avgMsPerFrame: avgMsPerFrame.toFixed(1),
174
+ sinceLastLog: (now - lastFrameTime).toFixed(1) + 'ms',
175
+ });
176
+ lastFrameTime = now;
177
+ }
178
+ const scene = resolveTimeline(frame, project);
179
+ for (const tr of scene.transitions) {
180
+ if (transitionSnapshots.has(tr.id))
181
+ continue;
182
+ const fromVideo = scene.videos.find(v => v.id === tr.fromClipId);
183
+ const fromImage = scene.images.find(i => i.id === tr.fromClipId);
184
+ if (fromVideo) {
185
+ const sink = videoSinks.get(fromVideo.src);
186
+ const sourceTimeSec = (fromVideo.sourceFrame + 0.5) / fps;
187
+ const wrapped = sink ? await sink.getCanvas(sourceTimeSec) : null;
188
+ if (wrapped) {
189
+ const bmp = await createImageBitmap(wrapped.canvas);
190
+ transitionSnapshots.set(tr.id, { source: bmp, owned: true });
191
+ }
192
+ }
193
+ else if (fromImage) {
194
+ const bmp = imageBitmaps.get(fromImage.src);
195
+ if (bmp)
196
+ transitionSnapshots.set(tr.id, { source: bmp, owned: false });
197
+ }
198
+ }
199
+ await renderFrame(ctx2d, scene, videoSinks, imageBitmaps, transitionSnapshots, width, height, fps, frame);
200
+ await canvasSource.add(frame / fps, 1 / fps);
201
+ const activeIds = new Set(scene.transitions.map(tr => tr.id));
202
+ for (const [id, snap] of transitionSnapshots) {
203
+ if (!activeIds.has(id)) {
204
+ if (snap.owned)
205
+ snap.source.close();
206
+ transitionSnapshots.delete(id);
207
+ }
208
+ }
209
+ const msg = { type: 'progress', frame, totalFrames };
210
+ self.postMessage(msg);
211
+ }
212
+ const loopMs = performance.now() - loopStart;
213
+ xlog('frames', `loop complete`, {
214
+ totalFrames,
215
+ totalMs: loopMs.toFixed(0),
216
+ avgMsPerFrame: (loopMs / totalFrames).toFixed(1),
217
+ });
218
+ await timed('mediabunny', 'output.finalize()', () => output.finalize());
219
+ if (!bufferTarget.buffer)
220
+ throw new Error('ExportWorker: buffer is null after finalize');
221
+ xlog('run', `export complete`, { outputSize: fmtBytes(bufferTarget.buffer.byteLength) });
222
+ return bufferTarget.buffer;
223
+ }
224
+ async function addAudioMix(source, audio) {
225
+ const { sampleRate, numberOfChannels, length } = audio;
226
+ const channels = audio.channels.map(b => new Float32Array(b));
227
+ const chunkFrames = sampleRate;
228
+ for (let start = 0; start < length; start += chunkFrames) {
229
+ const frames = Math.min(chunkFrames, length - start);
230
+ const data = new Float32Array(frames * numberOfChannels);
231
+ for (let c = 0; c < numberOfChannels; c++) {
232
+ data.set(channels[c].subarray(start, start + frames), c * frames);
233
+ }
234
+ const sample = new mb.AudioSample({
235
+ data,
236
+ format: 'f32-planar',
237
+ numberOfChannels,
238
+ sampleRate,
239
+ timestamp: start / sampleRate,
240
+ });
241
+ await source.add(sample);
242
+ sample.close();
243
+ }
244
+ }
245
+ async function renderFrame(ctx, scene, videoSinks, imageBitmaps, transitionSnapshots, stageW, stageH, fps, frameIndex) {
246
+ const isDebugFrame = frameIndex === 0;
247
+ if (isDebugFrame) {
248
+ xlog('render:frame0', `scene layers`, {
249
+ videos: scene.videos.length,
250
+ images: scene.images.length,
251
+ texts: scene.texts.length,
252
+ });
253
+ }
254
+ ctx.clearRect(0, 0, stageW, stageH);
255
+ ctx.fillStyle = '#000';
256
+ ctx.fillRect(0, 0, stageW, stageH);
257
+ const items = [
258
+ ...scene.videos.map(item => ({ kind: 'video', item })),
259
+ ...scene.images.map(item => ({ kind: 'image', item })),
260
+ ...scene.texts.map(item => ({ kind: 'text', item })),
261
+ ].sort((a, b) => a.item.zIndex - b.item.zIndex);
262
+ for (const entry of items) {
263
+ ctx.save();
264
+ ctx.globalAlpha = entry.item.opacity ?? 1;
265
+ if (entry.kind === 'video') {
266
+ const sink = videoSinks.get(entry.item.src);
267
+ if (sink) {
268
+ const sourceTimeSec = (entry.item.sourceFrame + 0.5) / fps;
269
+ if (isDebugFrame) {
270
+ xlog('render:frame0', `video layer — seeking CanvasSink`, {
271
+ src: entry.item.src.slice(-40),
272
+ sourceTimeSec: sourceTimeSec.toFixed(4),
273
+ zIndex: entry.item.zIndex,
274
+ opacity: entry.item.opacity ?? 1,
275
+ });
276
+ }
277
+ const seekStart = isDebugFrame ? performance.now() : 0;
278
+ const wrapped = await sink.getCanvas(sourceTimeSec);
279
+ if (isDebugFrame) {
280
+ xlog('render:frame0', `video layer — getCanvas()`, {
281
+ gotFrame: !!wrapped,
282
+ ms: (performance.now() - seekStart).toFixed(1),
283
+ ...(wrapped ? { canvasW: wrapped.canvas.width, canvasH: wrapped.canvas.height } : {}),
284
+ });
285
+ }
286
+ if (wrapped) {
287
+ drawMedia(ctx, wrapped.canvas, entry.item.transform, stageW, stageH);
288
+ }
289
+ }
290
+ else {
291
+ if (isDebugFrame)
292
+ xlog('render:frame0', `video layer — WARNING: no CanvasSink for src "${entry.item.src.slice(-40)}"`);
293
+ }
294
+ }
295
+ else if (entry.kind === 'image') {
296
+ const bitmap = imageBitmaps.get(entry.item.src);
297
+ if (isDebugFrame) {
298
+ xlog('render:frame0', `image layer`, {
299
+ src: entry.item.src.slice(-40),
300
+ zIndex: entry.item.zIndex,
301
+ hasBitmap: !!bitmap,
302
+ ...(bitmap ? { bitmapW: bitmap.width, bitmapH: bitmap.height } : {}),
303
+ });
304
+ }
305
+ if (bitmap) {
306
+ drawMedia(ctx, bitmap, entry.item.transform, stageW, stageH);
307
+ }
308
+ }
309
+ else {
310
+ if (isDebugFrame) {
311
+ xlog('render:frame0', `text layer`, {
312
+ content: entry.item.content?.slice(0, 30),
313
+ zIndex: entry.item.zIndex,
314
+ fontSize: entry.item.fontSize,
315
+ });
316
+ }
317
+ drawText(ctx, entry.item, stageW, stageH);
318
+ }
319
+ ctx.restore();
320
+ }
321
+ for (const tr of scene.transitions) {
322
+ const snap = transitionSnapshots.get(tr.id);
323
+ if (!snap)
324
+ continue;
325
+ ctx.save();
326
+ if (tr.kind === 'slide') {
327
+ const sign = tr.direction === 'left' ? -1 : 1;
328
+ ctx.translate(sign * tr.t * stageW, 0);
329
+ drawMedia(ctx, snap.source, undefined, stageW, stageH);
330
+ }
331
+ else if (tr.kind === 'wipe') {
332
+ ctx.beginPath();
333
+ ctx.rect(0, 0, stageW * (1 - tr.t), stageH);
334
+ ctx.clip();
335
+ drawMedia(ctx, snap.source, undefined, stageW, stageH);
336
+ }
337
+ else {
338
+ ctx.globalAlpha = 1 - tr.t;
339
+ drawMedia(ctx, snap.source, undefined, stageW, stageH);
340
+ }
341
+ ctx.restore();
342
+ }
343
+ if (isDebugFrame) {
344
+ xlog('render:frame0', `frame 0 draw complete — ${items.length} layer(s) composited onto ${stageW}x${stageH}`);
345
+ }
346
+ }
347
+ function drawMedia(ctx, source, transform, stageW, stageH) {
348
+ const rect = resolveDrawRect(transform, stageW, stageH, source.width, source.height);
349
+ const cx = rect.x + rect.width / 2;
350
+ const cy = rect.y + rect.height / 2;
351
+ ctx.translate(cx, cy);
352
+ ctx.rotate(rect.rotation);
353
+ ctx.drawImage(source, -rect.width / 2, -rect.height / 2, rect.width, rect.height);
354
+ }
355
+ function drawText(ctx, clip, stageW, stageH) {
356
+ const layout = computeTextLayout(ctx, clip, { width: stageW, height: stageH });
357
+ ctx.fillStyle = layout.style.color;
358
+ ctx.textAlign = layout.style.textAlign;
359
+ ctx.textBaseline = 'middle';
360
+ const rotation = clip.transform?.rotation ?? 0;
361
+ if (rotation !== 0) {
362
+ const cx = layout.center.x * stageW;
363
+ const cy = layout.center.y * stageH;
364
+ ctx.translate(cx, cy);
365
+ ctx.rotate(rotation);
366
+ ctx.translate(-cx, -cy);
367
+ }
368
+ for (let i = 0; i < layout.lines.length; i++) {
369
+ ctx.fillText(layout.lines[i], layout.anchorX, layout.firstLineY + i * layout.lineAdvance);
370
+ }
371
+ }
@@ -0,0 +1,3 @@
1
+ import type { Project } from '../types';
2
+ import type { ExportOptions } from './types';
3
+ export declare function exportVideo(project: Project, options?: ExportOptions): Promise<Blob>;
@@ -0,0 +1,118 @@
1
+ import { getTotalFrames } from '../utils/frames';
2
+ import { trace, traceEnabled, getEnabledChannels } from '../debug/trace';
3
+ function mlog(channel, msg) {
4
+ if (!traceEnabled(channel))
5
+ return;
6
+ trace(channel, `[main] ${msg}`);
7
+ }
8
+ async function renderAudioMix(project) {
9
+ const fps = project.fps;
10
+ const totalFrames = getTotalFrames(project.clips);
11
+ if (totalFrames === 0)
12
+ return null;
13
+ const allClips = Object.values(project.clips).flat();
14
+ const audioClips = allClips.filter(c => {
15
+ if (c.type !== 'audio' || !c.src || c.disabled)
16
+ return false;
17
+ const track = project.tracks.find(t => t.id === c.trackId);
18
+ return track && !track.muted && !track.disabled;
19
+ });
20
+ if (audioClips.length === 0) {
21
+ mlog('EXPORT_AUDIO', 'no active audio clips — exporting video-only');
22
+ return null;
23
+ }
24
+ const OAC = typeof OfflineAudioContext !== 'undefined'
25
+ ? OfflineAudioContext
26
+ : globalThis.webkitOfflineAudioContext;
27
+ if (!OAC) {
28
+ console.warn('[export:main] OfflineAudioContext unavailable — audio will be skipped');
29
+ return null;
30
+ }
31
+ const sampleRate = 44100;
32
+ const totalSec = totalFrames / fps;
33
+ mlog('EXPORT_AUDIO', `mixing ${audioClips.length} audio clip(s) — ${totalSec.toFixed(2)}s @ ${sampleRate}Hz`);
34
+ const ctx = new OAC(2, Math.ceil(sampleRate * totalSec), sampleRate);
35
+ for (const clip of audioClips) {
36
+ try {
37
+ const buf = await fetch(clip.src).then(r => r.arrayBuffer());
38
+ const decoded = await ctx.decodeAudioData(buf);
39
+ const node = ctx.createBufferSource();
40
+ node.buffer = decoded;
41
+ const gain = ctx.createGain();
42
+ gain.gain.value = clip.volume ?? 1;
43
+ node.connect(gain).connect(ctx.destination);
44
+ node.start(clip.startFrame / fps, clip.sourceStartFrame / fps, clip.durationFrames / fps);
45
+ }
46
+ catch (err) {
47
+ console.warn(`[export:main] audio clip "${clip.src?.slice(-40)}" skipped — decode error: ${String(err)}`);
48
+ }
49
+ }
50
+ const mixed = await ctx.startRendering();
51
+ const numberOfChannels = mixed.numberOfChannels;
52
+ const length = mixed.length;
53
+ const channels = [];
54
+ for (let c = 0; c < numberOfChannels; c++) {
55
+ channels.push(new Float32Array(mixed.getChannelData(c)).buffer);
56
+ }
57
+ mlog('EXPORT_AUDIO', `audio mix ready — channels=${numberOfChannels} frames=${length}`);
58
+ return { sampleRate, numberOfChannels, length, channels };
59
+ }
60
+ export async function exportVideo(project, options = {}) {
61
+ const t0 = performance.now();
62
+ const totalClips = Object.values(project.clips).flat().length;
63
+ mlog('EXPORT', `exportVideo() called — stage=${project.stage.width}x${project.stage.height} fps=${project.fps} clips=${totalClips}`);
64
+ const audio = await renderAudioMix(project);
65
+ const { signal } = options;
66
+ if (signal?.aborted)
67
+ return Promise.reject(signal.reason ?? new DOMException('Export aborted', 'AbortError'));
68
+ return new Promise((resolve, reject) => {
69
+ mlog('EXPORT', 'spawning ExportWorker (module worker)');
70
+ const worker = new Worker(new URL('./ExportWorker.ts', import.meta.url), { type: 'module' });
71
+ const abort = () => {
72
+ worker.terminate();
73
+ mlog('EXPORT', 'aborted by signal');
74
+ reject(signal?.reason ?? new DOMException('Export aborted', 'AbortError'));
75
+ };
76
+ signal?.addEventListener('abort', abort, { once: true });
77
+ let lastLoggedPct = -1;
78
+ worker.onmessage = (e) => {
79
+ const msg = e.data;
80
+ if (msg.type === 'progress') {
81
+ options.onProgress?.({ frame: msg.frame, totalFrames: msg.totalFrames });
82
+ const pct = Math.round((msg.frame / msg.totalFrames) * 100);
83
+ if (pct !== lastLoggedPct && pct % 10 === 0) {
84
+ lastLoggedPct = pct;
85
+ mlog('EXPORT_FRAMES', `progress ${pct}% (frame ${msg.frame}/${msg.totalFrames})`);
86
+ }
87
+ }
88
+ else if (msg.type === 'done') {
89
+ signal?.removeEventListener('abort', abort);
90
+ worker.terminate();
91
+ const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
92
+ const blob = new Blob([msg.buffer], { type: 'video/mp4' });
93
+ mlog('EXPORT', `done — blob=${(blob.size / 1000000).toFixed(2)}MB totalTime=${elapsed}s`);
94
+ resolve(blob);
95
+ }
96
+ else if (msg.type === 'error') {
97
+ signal?.removeEventListener('abort', abort);
98
+ worker.terminate();
99
+ console.error(`[export:main] worker reported error: ${msg.message}`);
100
+ reject(new Error(msg.message));
101
+ }
102
+ };
103
+ worker.onerror = (e) => {
104
+ signal?.removeEventListener('abort', abort);
105
+ worker.terminate();
106
+ console.error(`[export:main] worker crashed: ${e.message}`);
107
+ reject(new Error(`ExportWorker crashed: ${e.message}`));
108
+ };
109
+ mlog('EXPORT', 'posting start message to worker');
110
+ worker.postMessage({
111
+ type: 'start',
112
+ project,
113
+ options: { ...options, onProgress: undefined, signal: undefined },
114
+ audio,
115
+ trace: getEnabledChannels(),
116
+ }, audio ? audio.channels : []);
117
+ });
118
+ }
@@ -0,0 +1,2 @@
1
+ export { exportVideo } from './exportVideo';
2
+ export type { ExportOptions, ExportProgress, ExportVideoCodec, ExportAudioCodec } from './types';
@@ -0,0 +1 @@
1
+ export { exportVideo } from './exportVideo';
@@ -0,0 +1,4 @@
1
+ import type { Project } from '../types';
2
+ import type { ExportOptions } from './types';
3
+ export declare function lazyExportVideo(project: Project, options?: ExportOptions): Promise<Blob>;
4
+ export type { ExportOptions, ExportProgress, ExportVideoCodec, ExportAudioCodec } from './types';
@@ -0,0 +1,4 @@
1
+ export async function lazyExportVideo(project, options) {
2
+ const { exportVideo: actualExport } = await import('./exportVideo');
3
+ return actualExport(project, options);
4
+ }
@@ -0,0 +1,37 @@
1
+ export type ExportVideoCodec = 'avc' | 'vp9' | 'vp8';
2
+ export type ExportAudioCodec = 'aac' | 'opus';
3
+ export interface ExportOptions {
4
+ videoCodec?: ExportVideoCodec;
5
+ audioCodec?: ExportAudioCodec;
6
+ videoBitrate?: number;
7
+ audioBitrate?: number;
8
+ onProgress?: (progress: ExportProgress) => void;
9
+ signal?: AbortSignal;
10
+ }
11
+ export interface ExportProgress {
12
+ frame: number;
13
+ totalFrames: number;
14
+ }
15
+ export interface RenderedAudio {
16
+ sampleRate: number;
17
+ numberOfChannels: number;
18
+ length: number;
19
+ channels: ArrayBuffer[];
20
+ }
21
+ export type WorkerInMessage = {
22
+ type: 'start';
23
+ project: unknown;
24
+ options: ExportOptions;
25
+ audio: RenderedAudio | null;
26
+ };
27
+ export type WorkerOutMessage = {
28
+ type: 'progress';
29
+ frame: number;
30
+ totalFrames: number;
31
+ } | {
32
+ type: 'done';
33
+ buffer: ArrayBuffer;
34
+ } | {
35
+ type: 'error';
36
+ message: string;
37
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ export type { Clip, Track, Project, Transform, TextAnimation, TextAnimationKind, ClipType, TrackKind, FrameCount, TimelineConfig, InitialTrackConfig, EngineEvent, Transition, TransitionKind, TransitionEasing, TransitionDirection, } from './types';
2
+ export { TimelineEngine } from './editor/TimelineEngine';
3
+ export { PlaybackEngine } from './playback/PlaybackEngine';
4
+ export type { PlaybackSnapshot, PlaybackEngineConfig } from './playback/PlaybackEngine';
5
+ export { resolveTimeline } from './resolver/resolveTimeline';
6
+ export type { Scene, ActiveTransition, ActiveVideoClip, ActiveAudioClip, ActiveTextClip, ActiveImageClip, ActiveClipBase, } from './resolver/scene';
7
+ export type { Renderer } from './renderer/types';
8
+ export { GpuRenderer } from './renderer/gpu/GpuRenderer';
9
+ export type { RendererOptions } from './renderer/gpu/types';
10
+ export { resolveDrawRect, transformFromContainRect } from './renderer/gpu/layers/drawRect';
11
+ export { computeContainViewport } from './renderer/gpu/viewport';
12
+ export { computeTextLayout, SIDE_MARGIN } from './renderer/gpu/layers/textLayout';
13
+ export type { TextLayout } from './renderer/gpu/layers/textLayout';
14
+ export type { DemuxerBackend as MediabunnyDemuxer } from './media/video/demuxer/MediabunnyDemuxer';
15
+ export { GpuDebugCounters } from './renderer/gpu/debug/GpuDebugCounters';
16
+ export type { CounterSnapshot } from './renderer/gpu/debug/GpuDebugCounters';
17
+ export { createMediabunnyBackend, isMediabunnyCompatible } from './media/video/demuxer/createMediabunnyBackend';
18
+ export type { MediabunnyModule, CreateMediabunnyBackendOpts } from './media/video/demuxer/createMediabunnyBackend';
19
+ export type { DemuxerBackend, DemuxerFactory } from './media/video/demuxer/MediabunnyDemuxer';
20
+ export type { VideoFrameProvider, VideoFrameProviderDeps } from './media/video';
21
+ export { createVideoFrameProvider, MockVideoFrameProvider, SyntheticVideoFrameProvider } from './media/video';
22
+ export { AudioPlaybackController } from './media/audio/AudioPlaybackController';
23
+ export type { AudioPlaybackControllerOptions } from './media/audio/AudioPlaybackController';
24
+ export { useMediaLibrary, useMediaLibraryStore, MEDIA_DRAG_MIME, importFiles } from './assets';
25
+ export type { MediaAsset, MediaKind, DragMediaPayload, ImportFilesOptions, ImportFilesResult, SkippedImport } from './assets';
26
+ export { useTracksStore } from './stores/tracks.store';
27
+ export type { TracksState, TracksActions } from './stores/tracks.store';
28
+ export { usePlaybackStore } from './stores/playback.store';
29
+ export type { PlaybackState, PlaybackActions } from './stores/playback.store';
30
+ export { useSelectionStore } from './stores/selection.store';
31
+ export type { SelectionState, SelectionActions } from './stores/selection.store';
32
+ export { useTransitionsStore } from './stores/transitions.store';
33
+ export type { CreateClipOptions } from './elements/base';
34
+ export { createVideoClip } from './elements/video';
35
+ export { createAudioClip } from './elements/audio';
36
+ export { createTextClip } from './elements/text';
37
+ export { createImageClip } from './elements/image';
38
+ export { splitClipAtPlayhead } from './actions/splitClipAtPlayhead';
39
+ export type { SplitAtPlayheadData } from './actions/splitClipAtPlayhead';
40
+ export type { ActionResult, ActionFailureReason } from './actions/types';
41
+ export { framesToTimecode, secondsToFrames, framesToSeconds, getTotalFrames, clipsOverlap } from './utils/frames';
42
+ export { generateId } from './utils/id';
43
+ export { snapFrame, buildSnapPoints, resolveOverlapEdgeSnap, DEFAULT_OVERLAP_TOLERANCE } from './utils/snap';
44
+ export { exportVideo } from './export';
45
+ export { lazyExportVideo } from './export/lazyExport';
46
+ export type { ExportOptions, ExportProgress, ExportVideoCodec, ExportAudioCodec } from './export';
47
+ export { useEditor, useTimelineEngine, usePlaybackEngine, EditorContext, } from './editor-context';
48
+ export { installTraceGlobal, trace } from './debug/trace';
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ export { TimelineEngine } from './editor/TimelineEngine';
2
+ export { PlaybackEngine } from './playback/PlaybackEngine';
3
+ export { resolveTimeline } from './resolver/resolveTimeline';
4
+ export { GpuRenderer } from './renderer/gpu/GpuRenderer';
5
+ export { resolveDrawRect, transformFromContainRect } from './renderer/gpu/layers/drawRect';
6
+ export { computeContainViewport } from './renderer/gpu/viewport';
7
+ export { computeTextLayout, SIDE_MARGIN } from './renderer/gpu/layers/textLayout';
8
+ export { GpuDebugCounters } from './renderer/gpu/debug/GpuDebugCounters';
9
+ export { createMediabunnyBackend, isMediabunnyCompatible } from './media/video/demuxer/createMediabunnyBackend';
10
+ export { createVideoFrameProvider, MockVideoFrameProvider, SyntheticVideoFrameProvider } from './media/video';
11
+ export { AudioPlaybackController } from './media/audio/AudioPlaybackController';
12
+ export { useMediaLibrary, useMediaLibraryStore, MEDIA_DRAG_MIME, importFiles } from './assets';
13
+ export { useTracksStore } from './stores/tracks.store';
14
+ export { usePlaybackStore } from './stores/playback.store';
15
+ export { useSelectionStore } from './stores/selection.store';
16
+ export { useTransitionsStore } from './stores/transitions.store';
17
+ export { createVideoClip } from './elements/video';
18
+ export { createAudioClip } from './elements/audio';
19
+ export { createTextClip } from './elements/text';
20
+ export { createImageClip } from './elements/image';
21
+ export { splitClipAtPlayhead } from './actions/splitClipAtPlayhead';
22
+ export { framesToTimecode, secondsToFrames, framesToSeconds, getTotalFrames, clipsOverlap } from './utils/frames';
23
+ export { generateId } from './utils/id';
24
+ export { snapFrame, buildSnapPoints, resolveOverlapEdgeSnap, DEFAULT_OVERLAP_TOLERANCE } from './utils/snap';
25
+ export { exportVideo } from './export';
26
+ export { lazyExportVideo } from './export/lazyExport';
27
+ export { useEditor, useTimelineEngine, usePlaybackEngine, EditorContext, } from './editor-context';
28
+ export { installTraceGlobal, trace } from './debug/trace';
@@ -0,0 +1,26 @@
1
+ import type { PlaybackEngine } from '../../playback/PlaybackEngine';
2
+ import type { Project } from '../../types';
3
+ export interface AudioPlaybackControllerOptions {
4
+ audioContextFactory?: () => AudioContext;
5
+ }
6
+ export declare class AudioPlaybackController {
7
+ private readonly _playback;
8
+ private readonly _getProject;
9
+ private readonly _audioContextFactory;
10
+ private _ctx;
11
+ private readonly _buffers;
12
+ private _node;
13
+ private _gain;
14
+ private _activeClipId;
15
+ private _lastEpoch;
16
+ private _scheduleToken;
17
+ private _unsub;
18
+ constructor(playback: PlaybackEngine, getProject: () => Project, options?: AudioPlaybackControllerOptions);
19
+ start(): void;
20
+ destroy(): void;
21
+ private _onSnapshot;
22
+ private _schedule;
23
+ private _startNode;
24
+ private _stop;
25
+ private _ensureBuffer;
26
+ }