@clipkit/runtime 1.0.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 (219) hide show
  1. package/LICENSE +54 -0
  2. package/README.md +98 -0
  3. package/dist/animation/easings.d.ts +9 -0
  4. package/dist/animation/easings.d.ts.map +1 -0
  5. package/dist/animation/easings.js +230 -0
  6. package/dist/animation/easings.js.map +1 -0
  7. package/dist/animation/expr.d.ts +44 -0
  8. package/dist/animation/expr.d.ts.map +1 -0
  9. package/dist/animation/expr.js +236 -0
  10. package/dist/animation/expr.js.map +1 -0
  11. package/dist/animation/keyframes.d.ts +23 -0
  12. package/dist/animation/keyframes.d.ts.map +1 -0
  13. package/dist/animation/keyframes.js +117 -0
  14. package/dist/animation/keyframes.js.map +1 -0
  15. package/dist/animation/motion-path.d.ts +18 -0
  16. package/dist/animation/motion-path.d.ts.map +1 -0
  17. package/dist/animation/motion-path.js +269 -0
  18. package/dist/animation/motion-path.js.map +1 -0
  19. package/dist/animation/noise1d.d.ts +5 -0
  20. package/dist/animation/noise1d.d.ts.map +1 -0
  21. package/dist/animation/noise1d.js +27 -0
  22. package/dist/animation/noise1d.js.map +1 -0
  23. package/dist/animation/presets.d.ts +60 -0
  24. package/dist/animation/presets.d.ts.map +1 -0
  25. package/dist/animation/presets.js +221 -0
  26. package/dist/animation/presets.js.map +1 -0
  27. package/dist/assets/cache.d.ts +18 -0
  28. package/dist/assets/cache.d.ts.map +1 -0
  29. package/dist/assets/cache.js +56 -0
  30. package/dist/assets/cache.js.map +1 -0
  31. package/dist/assets/fonts.d.ts +20 -0
  32. package/dist/assets/fonts.d.ts.map +1 -0
  33. package/dist/assets/fonts.js +127 -0
  34. package/dist/assets/fonts.js.map +1 -0
  35. package/dist/assets/loader.d.ts +18 -0
  36. package/dist/assets/loader.d.ts.map +1 -0
  37. package/dist/assets/loader.js +87 -0
  38. package/dist/assets/loader.js.map +1 -0
  39. package/dist/assets/lut.d.ts +5 -0
  40. package/dist/assets/lut.d.ts.map +1 -0
  41. package/dist/assets/lut.js +77 -0
  42. package/dist/assets/lut.js.map +1 -0
  43. package/dist/assets/media-time.d.ts +31 -0
  44. package/dist/assets/media-time.d.ts.map +1 -0
  45. package/dist/assets/media-time.js +65 -0
  46. package/dist/assets/media-time.js.map +1 -0
  47. package/dist/assets/mp4-frame-source.d.ts +44 -0
  48. package/dist/assets/mp4-frame-source.d.ts.map +1 -0
  49. package/dist/assets/mp4-frame-source.js +387 -0
  50. package/dist/assets/mp4-frame-source.js.map +1 -0
  51. package/dist/audio/encoder.d.ts +31 -0
  52. package/dist/audio/encoder.d.ts.map +1 -0
  53. package/dist/audio/encoder.js +96 -0
  54. package/dist/audio/encoder.js.map +1 -0
  55. package/dist/audio/fades.d.ts +16 -0
  56. package/dist/audio/fades.d.ts.map +1 -0
  57. package/dist/audio/fades.js +43 -0
  58. package/dist/audio/fades.js.map +1 -0
  59. package/dist/audio/limiter.d.ts +8 -0
  60. package/dist/audio/limiter.d.ts.map +1 -0
  61. package/dist/audio/limiter.js +39 -0
  62. package/dist/audio/limiter.js.map +1 -0
  63. package/dist/audio/loader.d.ts +6 -0
  64. package/dist/audio/loader.d.ts.map +1 -0
  65. package/dist/audio/loader.js +42 -0
  66. package/dist/audio/loader.js.map +1 -0
  67. package/dist/audio/mixer.d.ts +17 -0
  68. package/dist/audio/mixer.d.ts.map +1 -0
  69. package/dist/audio/mixer.js +204 -0
  70. package/dist/audio/mixer.js.map +1 -0
  71. package/dist/audio/varispeed.d.ts +24 -0
  72. package/dist/audio/varispeed.d.ts.map +1 -0
  73. package/dist/audio/varispeed.js +114 -0
  74. package/dist/audio/varispeed.js.map +1 -0
  75. package/dist/audio/wav.d.ts +6 -0
  76. package/dist/audio/wav.d.ts.map +1 -0
  77. package/dist/audio/wav.js +62 -0
  78. package/dist/audio/wav.js.map +1 -0
  79. package/dist/backend/backend.d.ts +579 -0
  80. package/dist/backend/backend.d.ts.map +1 -0
  81. package/dist/backend/backend.js +17 -0
  82. package/dist/backend/backend.js.map +1 -0
  83. package/dist/backend/webgl-backend.d.ts +97 -0
  84. package/dist/backend/webgl-backend.d.ts.map +1 -0
  85. package/dist/backend/webgl-backend.js +2142 -0
  86. package/dist/backend/webgl-backend.js.map +1 -0
  87. package/dist/backend/webgpu-backend.d.ts +121 -0
  88. package/dist/backend/webgpu-backend.d.ts.map +1 -0
  89. package/dist/backend/webgpu-backend.js +2481 -0
  90. package/dist/backend/webgpu-backend.js.map +1 -0
  91. package/dist/compositor/bitfont.d.ts +8 -0
  92. package/dist/compositor/bitfont.d.ts.map +1 -0
  93. package/dist/compositor/bitfont.js +52 -0
  94. package/dist/compositor/bitfont.js.map +1 -0
  95. package/dist/compositor/camera.d.ts +5 -0
  96. package/dist/compositor/camera.d.ts.map +1 -0
  97. package/dist/compositor/camera.js +114 -0
  98. package/dist/compositor/camera.js.map +1 -0
  99. package/dist/compositor/color.d.ts +26 -0
  100. package/dist/compositor/color.d.ts.map +1 -0
  101. package/dist/compositor/color.js +189 -0
  102. package/dist/compositor/color.js.map +1 -0
  103. package/dist/compositor/element-renderers/caption.d.ts +4 -0
  104. package/dist/compositor/element-renderers/caption.d.ts.map +1 -0
  105. package/dist/compositor/element-renderers/caption.js +376 -0
  106. package/dist/compositor/element-renderers/caption.js.map +1 -0
  107. package/dist/compositor/element-renderers/group.d.ts +12 -0
  108. package/dist/compositor/element-renderers/group.d.ts.map +1 -0
  109. package/dist/compositor/element-renderers/group.js +259 -0
  110. package/dist/compositor/element-renderers/group.js.map +1 -0
  111. package/dist/compositor/element-renderers/image.d.ts +4 -0
  112. package/dist/compositor/element-renderers/image.d.ts.map +1 -0
  113. package/dist/compositor/element-renderers/image.js +97 -0
  114. package/dist/compositor/element-renderers/image.js.map +1 -0
  115. package/dist/compositor/element-renderers/lit.d.ts +6 -0
  116. package/dist/compositor/element-renderers/lit.d.ts.map +1 -0
  117. package/dist/compositor/element-renderers/lit.js +82 -0
  118. package/dist/compositor/element-renderers/lit.js.map +1 -0
  119. package/dist/compositor/element-renderers/particles.d.ts +4 -0
  120. package/dist/compositor/element-renderers/particles.d.ts.map +1 -0
  121. package/dist/compositor/element-renderers/particles.js +212 -0
  122. package/dist/compositor/element-renderers/particles.js.map +1 -0
  123. package/dist/compositor/element-renderers/shape.d.ts +4 -0
  124. package/dist/compositor/element-renderers/shape.d.ts.map +1 -0
  125. package/dist/compositor/element-renderers/shape.js +171 -0
  126. package/dist/compositor/element-renderers/shape.js.map +1 -0
  127. package/dist/compositor/element-renderers/svg.d.ts +4 -0
  128. package/dist/compositor/element-renderers/svg.d.ts.map +1 -0
  129. package/dist/compositor/element-renderers/svg.js +210 -0
  130. package/dist/compositor/element-renderers/svg.js.map +1 -0
  131. package/dist/compositor/element-renderers/text.d.ts +25 -0
  132. package/dist/compositor/element-renderers/text.d.ts.map +1 -0
  133. package/dist/compositor/element-renderers/text.js +1358 -0
  134. package/dist/compositor/element-renderers/text.js.map +1 -0
  135. package/dist/compositor/element-renderers/video.d.ts +12 -0
  136. package/dist/compositor/element-renderers/video.d.ts.map +1 -0
  137. package/dist/compositor/element-renderers/video.js +109 -0
  138. package/dist/compositor/element-renderers/video.js.map +1 -0
  139. package/dist/compositor/fit.d.ts +18 -0
  140. package/dist/compositor/fit.d.ts.map +1 -0
  141. package/dist/compositor/fit.js +106 -0
  142. package/dist/compositor/fit.js.map +1 -0
  143. package/dist/compositor/lighting.d.ts +63 -0
  144. package/dist/compositor/lighting.d.ts.map +1 -0
  145. package/dist/compositor/lighting.js +141 -0
  146. package/dist/compositor/lighting.js.map +1 -0
  147. package/dist/compositor/mat4.d.ts +88 -0
  148. package/dist/compositor/mat4.d.ts.map +1 -0
  149. package/dist/compositor/mat4.js +245 -0
  150. package/dist/compositor/mat4.js.map +1 -0
  151. package/dist/compositor/project.d.ts +24 -0
  152. package/dist/compositor/project.d.ts.map +1 -0
  153. package/dist/compositor/project.js +105 -0
  154. package/dist/compositor/project.js.map +1 -0
  155. package/dist/compositor/render-context.d.ts +194 -0
  156. package/dist/compositor/render-context.d.ts.map +1 -0
  157. package/dist/compositor/render-context.js +10 -0
  158. package/dist/compositor/render-context.js.map +1 -0
  159. package/dist/compositor/resolve.d.ts +80 -0
  160. package/dist/compositor/resolve.d.ts.map +1 -0
  161. package/dist/compositor/resolve.js +276 -0
  162. package/dist/compositor/resolve.js.map +1 -0
  163. package/dist/compositor/scene.d.ts +10 -0
  164. package/dist/compositor/scene.d.ts.map +1 -0
  165. package/dist/compositor/scene.js +658 -0
  166. package/dist/compositor/scene.js.map +1 -0
  167. package/dist/compositor/transform.d.ts +73 -0
  168. package/dist/compositor/transform.d.ts.map +1 -0
  169. package/dist/compositor/transform.js +229 -0
  170. package/dist/compositor/transform.js.map +1 -0
  171. package/dist/compositor/unit.d.ts +27 -0
  172. package/dist/compositor/unit.d.ts.map +1 -0
  173. package/dist/compositor/unit.js +74 -0
  174. package/dist/compositor/unit.js.map +1 -0
  175. package/dist/encoder/exporter.d.ts +95 -0
  176. package/dist/encoder/exporter.d.ts.map +1 -0
  177. package/dist/encoder/exporter.js +341 -0
  178. package/dist/encoder/exporter.js.map +1 -0
  179. package/dist/encoder/index.d.ts +3 -0
  180. package/dist/encoder/index.d.ts.map +1 -0
  181. package/dist/encoder/index.js +2 -0
  182. package/dist/encoder/index.js.map +1 -0
  183. package/dist/index.d.ts +12 -0
  184. package/dist/index.d.ts.map +1 -0
  185. package/dist/index.js +27 -0
  186. package/dist/index.js.map +1 -0
  187. package/dist/logger.d.ts +13 -0
  188. package/dist/logger.d.ts.map +1 -0
  189. package/dist/logger.js +32 -0
  190. package/dist/logger.js.map +1 -0
  191. package/dist/runtime.d.ts +216 -0
  192. package/dist/runtime.d.ts.map +1 -0
  193. package/dist/runtime.js +1012 -0
  194. package/dist/runtime.js.map +1 -0
  195. package/dist/svg/morph.d.ts +6 -0
  196. package/dist/svg/morph.d.ts.map +1 -0
  197. package/dist/svg/morph.js +62 -0
  198. package/dist/svg/morph.js.map +1 -0
  199. package/dist/svg/svg-renderer.d.ts +18 -0
  200. package/dist/svg/svg-renderer.d.ts.map +1 -0
  201. package/dist/svg/svg-renderer.js +142 -0
  202. package/dist/svg/svg-renderer.js.map +1 -0
  203. package/dist/text/caption-chunk.d.ts +17 -0
  204. package/dist/text/caption-chunk.d.ts.map +1 -0
  205. package/dist/text/caption-chunk.js +76 -0
  206. package/dist/text/caption-chunk.js.map +1 -0
  207. package/dist/text/font-atlas.d.ts +63 -0
  208. package/dist/text/font-atlas.d.ts.map +1 -0
  209. package/dist/text/font-atlas.js +225 -0
  210. package/dist/text/font-atlas.js.map +1 -0
  211. package/dist/text/measure.d.ts +38 -0
  212. package/dist/text/measure.d.ts.map +1 -0
  213. package/dist/text/measure.js +164 -0
  214. package/dist/text/measure.js.map +1 -0
  215. package/dist/text/text-animation.d.ts +52 -0
  216. package/dist/text/text-animation.d.ts.map +1 -0
  217. package/dist/text/text-animation.js +133 -0
  218. package/dist/text/text-animation.js.map +1 -0
  219. package/package.json +47 -0
@@ -0,0 +1,1012 @@
1
+ // ClipkitRuntime — public API for the compositor + encoder.
2
+ //
3
+ // Lifecycle:
4
+ // const rt = new ClipkitRuntime(canvas);
5
+ // const ok = await rt.init();
6
+ // if (!ok) { /* WebGPU not available */ }
7
+ // rt.load(source);
8
+ // await rt.preload(); // load images, videos, fonts
9
+ // rt.frame(time); // render one frame for preview
10
+ // const blob = await rt.export({ ... });
11
+ // rt.dispose();
12
+ import { WebGPUBackend } from './backend/webgpu-backend.js';
13
+ import { WebGL2Backend } from './backend/webgl-backend.js';
14
+ import { AssetCache } from './assets/cache.js';
15
+ import { loadFont, fontsReady, registerSourceFonts } from './assets/fonts.js';
16
+ import { loadImage, loadVideo, seekVideo } from './assets/loader.js';
17
+ import { loadCube } from './assets/lut.js';
18
+ import { Mp4FrameSource } from './assets/mp4-frame-source.js';
19
+ import { mapToMediaTime, rateOf, timeRemapOf, trimDurationOf } from './assets/media-time.js';
20
+ import { loadAudio } from './audio/loader.js';
21
+ import { mixSourceAudio } from './audio/mixer.js';
22
+ import { audioBufferToWav } from './audio/wav.js';
23
+ import { interpolateKeyframes } from './animation/keyframes.js';
24
+ import { renderSourceFrame } from './compositor/scene.js';
25
+ import { parseColorPremultiplied } from './compositor/color.js';
26
+ import { MAT4_IDENTITY } from './compositor/render-context.js';
27
+ import { cameraMatrix } from './compositor/camera.js';
28
+ import { resolveLights, resolveEnvironment, resolveBloom, cameraEyeWorld } from './compositor/lighting.js';
29
+ import { ClipkitExporter, resolveRenderResolution } from './encoder/exporter.js';
30
+ import { getLogger } from './logger.js';
31
+ export class ClipkitRuntime {
32
+ canvas;
33
+ backend;
34
+ currentSource = null;
35
+ /** When true, frame() clears to a transparent background if the Source has no
36
+ * background_color — set during alpha (transparent) frame export. */
37
+ transparentBackground = false;
38
+ images = new AssetCache();
39
+ videos = new AssetCache();
40
+ audioBuffers = new AssetCache();
41
+ fontAtlases = new AssetCache();
42
+ svgRasters = new AssetCache();
43
+ maskedTexts = new AssetCache();
44
+ groupTargets = new AssetCache();
45
+ /**
46
+ * Eviction epoch for the groupTargets pool — bumped once per frame() and once
47
+ * per renderFinalFrame(), threaded into RenderContext.frameIndex so pool
48
+ * entries can be stamped last-touched for frame-boundary LRU eviction. NOT a
49
+ * clean 1:1 output-frame ordinal: it advances per motion-blur SAMPLE (each
50
+ * export sample funnels through frame()) and the renderFinalFrame→frame()
51
+ * fallback bumps twice. Only ever compared for equality at eviction time, so
52
+ * gaps/jumps are harmless — do not treat it as a frame number.
53
+ */
54
+ frameIndex = 0;
55
+ /**
56
+ * Soft cap (bytes) on the offscreen-FBO pool (groupTargets). After each render
57
+ * pass's endFrame(), entries NOT touched this frame are evicted LRU-first
58
+ * until total pooled bytes (logical w*h*4 per target) drop under this. SOFT:
59
+ * targets touched this frame are never evicted, so a single frame whose
60
+ * working set exceeds the cap stays above it (graceful — never thrashes).
61
+ * Default 512 MiB: a glassy element peaks at ~3 full-surface 1080p targets
62
+ * (::bd + ::fx + ::fx-scratch ≈ 25 MB), so 512 MiB holds ~18-20
63
+ * concurrently-active glass elements — well above any real frame's live set —
64
+ * while bounding the cross-timeline tail of inactive-element keys that is the
65
+ * unbounded-growth / cloud-BLACK cause. Logical bytes ≈ real VRAM only when
66
+ * pixelRatio=1 (the cloud Job, renderResolution='source'); high-DPI preview
67
+ * under-accounts by pixelRatio² (harmless — just a looser bound off-cloud).
68
+ * Override via setGroupTargetPoolCap(); ≤ 0 disables eviction (legacy).
69
+ */
70
+ groupTargetPoolCapBytes = 512 * 1024 * 1024;
71
+ luts = new AssetCache();
72
+ /** Scratch targets for renderFinalFrame's blur accumulation. */
73
+ blurTargets = [];
74
+ initialized = false;
75
+ disposed = false;
76
+ constructor(canvas) {
77
+ this.canvas = canvas;
78
+ }
79
+ async init(options = {}) {
80
+ if (this.initialized)
81
+ return true;
82
+ const prefer = options.backend ?? 'auto';
83
+ if (prefer === 'webgpu' || prefer === 'auto') {
84
+ const webgpu = new WebGPUBackend(this.canvas);
85
+ if (await webgpu.init()) {
86
+ this.backend = webgpu;
87
+ this.initialized = true;
88
+ getLogger().info('Runtime initialized with WebGPU backend');
89
+ return true;
90
+ }
91
+ if (prefer === 'webgpu')
92
+ return false;
93
+ }
94
+ if (prefer === 'webgl2' || prefer === 'auto') {
95
+ const webgl = new WebGL2Backend(this.canvas);
96
+ if (await webgl.init()) {
97
+ this.backend = webgl;
98
+ this.initialized = true;
99
+ getLogger().info('Runtime initialized with WebGL2 backend');
100
+ return true;
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+ /** Which graphics API this runtime is using. Only valid after init() resolves true. */
106
+ get api() {
107
+ return this.initialized ? this.backend.capabilities.api : null;
108
+ }
109
+ /** Set the current source. Does NOT trigger asset loading — call preload(). */
110
+ load(source) {
111
+ this.currentSource = source;
112
+ // Resize the backend to match the source's intrinsic dimensions.
113
+ const w = source.width ?? 1920;
114
+ const h = source.height ?? 1080;
115
+ this.backend.resize(w, h);
116
+ }
117
+ /**
118
+ * Load every external asset referenced by the current source. Images
119
+ * and videos are downloaded; fonts are requested via FontFace API.
120
+ * Idempotent — already-loaded URLs are skipped.
121
+ */
122
+ async preload(source) {
123
+ if (!this.initialized)
124
+ throw new Error('ClipkitRuntime.preload() called before init()');
125
+ const src = source ?? this.currentSource;
126
+ if (!src)
127
+ throw new Error('No source loaded');
128
+ // Register Source-declared @font-face entries first. Each becomes a
129
+ // FontFace added to document.fonts so the subsequent loadFont()
130
+ // calls resolve against them. Without this, the runtime would fall
131
+ // back to whatever font the host page happens to have available.
132
+ if (src.fonts && src.fonts.length > 0) {
133
+ await registerSourceFonts(src.fonts);
134
+ }
135
+ const fontRequests = [];
136
+ const imageRequests = [];
137
+ const videoRequests = [];
138
+ const audioRequests = [];
139
+ const lutRequests = [];
140
+ // LUT effects can sit on any element at any nesting depth.
141
+ const lutUrls = new Set();
142
+ const scanLuts = (els) => {
143
+ for (const el of els) {
144
+ const effects = el.effects;
145
+ if (Array.isArray(effects)) {
146
+ for (const fx of effects) {
147
+ if (fx.type === 'lut' && typeof fx.source === 'string' && fx.source)
148
+ lutUrls.add(fx.source);
149
+ }
150
+ }
151
+ const kids = el.elements;
152
+ if (Array.isArray(kids))
153
+ scanLuts(kids);
154
+ }
155
+ };
156
+ scanLuts(src.elements);
157
+ for (const url of lutUrls)
158
+ lutRequests.push(this.preloadLut(url));
159
+ // One decoder + one texture per video URL (v1 simplification):
160
+ // elements sharing a URL share a playhead. When their timings
161
+ // diverge (different time/trim/rate/loop/time_remap) the last
162
+ // element's decoded frame wins for ALL of them and the decoder
163
+ // thrashes between positions — warn the author toward distinct
164
+ // URLs (a ?copy=N query suffix is enough).
165
+ const videoTimings = new Map();
166
+ const scanVideoTimings = (els) => {
167
+ for (const el of els) {
168
+ if (el.type === 'video' && typeof el.source === 'string' && el.source) {
169
+ const v = el;
170
+ const sig = JSON.stringify([v.time, v.trim_start, v.trim_duration, v.playback_rate, v.loop, v.time_remap]);
171
+ let set = videoTimings.get(v.source);
172
+ if (!set)
173
+ videoTimings.set(v.source, (set = new Set()));
174
+ set.add(sig);
175
+ }
176
+ const kids = el.elements;
177
+ if (Array.isArray(kids))
178
+ scanVideoTimings(kids);
179
+ }
180
+ };
181
+ scanVideoTimings(src.elements);
182
+ for (const [url, sigs] of videoTimings) {
183
+ if (sigs.size > 1) {
184
+ getLogger().warn(`${sigs.size} video elements share ${url} with different timings — they share ONE decoder, so all will show the same frames and seeking will thrash. Give each element its own URL (append e.g. ?copy=2).`);
185
+ }
186
+ }
187
+ // Recursive — group children need their assets too (a flat scan
188
+ // left videos/images nested in groups unloaded).
189
+ const scanAssets = (els) => {
190
+ for (const element of els) {
191
+ if (element.type === 'image') {
192
+ imageRequests.push(this.preloadImage(element));
193
+ }
194
+ else if (element.type === 'video') {
195
+ videoRequests.push(this.preloadVideo(element));
196
+ // Videos can carry an audio track; decode it for the scheduler /
197
+ // export mix. Quiet no-op for silent videos and in workers.
198
+ audioRequests.push(this.preloadVideoAudio(element));
199
+ }
200
+ else if (element.type === 'audio') {
201
+ audioRequests.push(this.preloadAudio(element));
202
+ }
203
+ else if (element.type === 'text' || element.type === 'caption') {
204
+ const text = element;
205
+ fontRequests.push(loadFont(text.font_family ?? 'sans-serif', text.font_weight ?? 'normal'));
206
+ }
207
+ else if (element.type === 'group') {
208
+ const kids = element.elements;
209
+ if (Array.isArray(kids))
210
+ scanAssets(kids);
211
+ }
212
+ // §4.8 Phase 2: a material normal map on ANY element type loads
213
+ // through the shared image cache.
214
+ const nm = element.material?.normal_map;
215
+ if (typeof nm === 'string' && nm) {
216
+ imageRequests.push(this.preloadImageUrl(nm));
217
+ }
218
+ }
219
+ };
220
+ scanAssets(src.elements);
221
+ // §4.8 Phase 3: an image environment loads as an equirect texture
222
+ // (shared image cache) and we cache its average color for the
223
+ // roughness-blurred reflection fallback.
224
+ const env = src.environment;
225
+ if (env && env.type === 'image' && typeof env.src === 'string' && env.src) {
226
+ imageRequests.push(this.preloadEnvImage(env.src));
227
+ }
228
+ // Global preload timeout — 20s — fail open if any asset path is
229
+ // stuck. Individual per-asset try/catches already swallow errors;
230
+ // this catches the case where a request neither rejects nor
231
+ // resolves (slow CDN, blocked CORS preflight, FontFaceSet stuck
232
+ // on a never-resolving load). Without it, "initializing…" can
233
+ // hang forever on a single bad asset.
234
+ // Per-asset timeouts: a single slow/stuck asset (e.g. a large remote video)
235
+ // must not hold the whole preload hostage. Each request resolves within
236
+ // PRELOAD_ASSET_TIMEOUT_MS; we log the category that ran long so it's
237
+ // diagnosable instead of a silent 20s stall. A longer global net still
238
+ // backstops a pathological case.
239
+ const PRELOAD_ASSET_TIMEOUT_MS = 10000;
240
+ const guard = (label, ps) => ps.map((p) => Promise.race([
241
+ p,
242
+ new Promise((resolve) => setTimeout(() => {
243
+ getLogger().warn(`Preload: a ${label} asset exceeded ${PRELOAD_ASSET_TIMEOUT_MS}ms — proceeding without waiting for it.`);
244
+ resolve();
245
+ }, PRELOAD_ASSET_TIMEOUT_MS)),
246
+ ]));
247
+ const allAssets = Promise.all([
248
+ ...guard('font', fontRequests),
249
+ ...guard('image', imageRequests),
250
+ ...guard('video', videoRequests),
251
+ ...guard('audio', audioRequests),
252
+ ...guard('lut', lutRequests),
253
+ ]);
254
+ const timeout = new Promise((resolve) => setTimeout(() => {
255
+ getLogger().warn('Preload global timeout — proceeding with whatever loaded.');
256
+ resolve();
257
+ }, 30000));
258
+ await Promise.race([allAssets, timeout]);
259
+ await Promise.race([fontsReady(), new Promise((resolve) => setTimeout(resolve, 2000))]);
260
+ }
261
+ async preloadAudio(element) {
262
+ const url = String(element.source ?? '');
263
+ if (!url)
264
+ return;
265
+ if (this.audioBuffers.has(url))
266
+ return;
267
+ try {
268
+ await this.audioBuffers.getOrLoad(url, async () => loadAudio(url));
269
+ }
270
+ catch (err) {
271
+ getLogger().warn(`Failed to preload audio ${url}:`, err instanceof Error ? err.message : String(err));
272
+ }
273
+ }
274
+ /**
275
+ * Decode a video's embedded audio track. decodeAudioData accepts MP4
276
+ * containers directly (it extracts the first audio track), so this is
277
+ * the same loadAudio path audio elements use. Failures are expected
278
+ * (video without audio) and logged at debug. Workers skip — the
279
+ * main-thread AudioScheduler owns preview audio.
280
+ */
281
+ async preloadVideoAudio(element) {
282
+ if (typeof AudioContext === 'undefined')
283
+ return;
284
+ const url = String(element.source ?? '');
285
+ if (!url)
286
+ return;
287
+ if (this.audioBuffers.has(url))
288
+ return;
289
+ try {
290
+ await this.audioBuffers.getOrLoad(url, async () => loadAudio(url));
291
+ }
292
+ catch (err) {
293
+ getLogger().debug(`Video ${url} has no decodable audio track:`, err instanceof Error ? err.message : String(err));
294
+ }
295
+ }
296
+ async preloadLut(url) {
297
+ if (this.luts.has(url))
298
+ return;
299
+ try {
300
+ await this.luts.getOrLoad(url, async () => {
301
+ const { bitmap, size } = await loadCube(url);
302
+ const texture = this.backend.createTexture(bitmap);
303
+ return { texture, size };
304
+ });
305
+ }
306
+ catch (err) {
307
+ getLogger().warn(`Failed to load LUT ${url}:`, err instanceof Error ? err.message : String(err));
308
+ }
309
+ }
310
+ async preloadImage(element) {
311
+ return this.preloadImageUrl(String(element.source ?? ''));
312
+ }
313
+ /**
314
+ * Preload an arbitrary image URL into the shared image cache (used by
315
+ * image elements AND material normal maps, §4.8 Phase 2). Normal maps
316
+ * upload as plain rgba8unorm and are sampled linearly — no premultiply
317
+ * or sRGB transform corrupts the encoded normals.
318
+ */
319
+ /** Average color of each loaded equirect environment image (§4.8). */
320
+ envAvg = new Map();
321
+ /**
322
+ * Preload an equirect environment image AND compute its average color
323
+ * (downscale to 1×1) for the roughness-blurred reflection fallback.
324
+ */
325
+ async preloadEnvImage(url) {
326
+ await this.preloadImageUrl(url);
327
+ if (this.envAvg.has(url))
328
+ return;
329
+ const asset = this.images.get(url);
330
+ if (!asset)
331
+ return;
332
+ try {
333
+ const oc = new OffscreenCanvas(1, 1);
334
+ const g = oc.getContext('2d');
335
+ if (!g)
336
+ return;
337
+ g.drawImage(asset.bitmap, 0, 0, 1, 1);
338
+ const d = g.getImageData(0, 0, 1, 1).data;
339
+ this.envAvg.set(url, [d[0] / 255, d[1] / 255, d[2] / 255]);
340
+ }
341
+ catch {
342
+ this.envAvg.set(url, [0.5, 0.5, 0.5]);
343
+ }
344
+ }
345
+ async preloadImageUrl(url) {
346
+ if (!url)
347
+ return;
348
+ if (this.images.has(url))
349
+ return;
350
+ // Wrap in try/catch — a single CORS-blocked or 404 image
351
+ // shouldn't kill the whole preload (which would block worker
352
+ // init). The image renderer already handles "no asset" by
353
+ // silently skipping the draw.
354
+ try {
355
+ await this.images.getOrLoad(url, async () => {
356
+ const bitmap = await loadImage(url);
357
+ const texture = this.backend.createTexture(bitmap);
358
+ return { bitmap, texture };
359
+ });
360
+ }
361
+ catch (err) {
362
+ getLogger().warn(`Failed to preload image ${url}:`, err instanceof Error ? err.message : String(err));
363
+ }
364
+ }
365
+ async preloadVideo(element) {
366
+ const url = String(element.source ?? '');
367
+ if (!url)
368
+ return;
369
+ if (this.videos.has(url))
370
+ return;
371
+ // Primary path in EVERY context: deterministic WebCodecs decode
372
+ // (fetch + demux + VideoDecoder). One pipeline for preview, editor
373
+ // export, and the render service — frame N is the same pixels
374
+ // everywhere. Fallbacks when the container/codec can't be handled:
375
+ // - page contexts → HTMLVideoElement (seek-based, approximate)
376
+ // - worker contexts → asset stays absent; the host pumps frames
377
+ // via pushExternalVideoFrame()
378
+ try {
379
+ await this.videos.getOrLoad(url, async () => {
380
+ const frameSource = await Mp4FrameSource.load(url);
381
+ const first = await frameSource.getFrame(0);
382
+ if (!first)
383
+ throw new Error('decoder produced no first frame');
384
+ const texture = this.backend.createTexture(first);
385
+ return {
386
+ video: null,
387
+ frameSource,
388
+ texture,
389
+ lastUploadedTime: first.timestamp,
390
+ width: frameSource.width,
391
+ height: frameSource.height,
392
+ };
393
+ });
394
+ return;
395
+ }
396
+ catch (err) {
397
+ const reason = err instanceof Error ? err.message : String(err);
398
+ if (typeof document === 'undefined') {
399
+ getLogger().warn(`WebCodecs decode unavailable for ${url} (${reason}); host frame-pump fallback required.`);
400
+ return;
401
+ }
402
+ getLogger().warn(`WebCodecs decode unavailable for ${url} (${reason}); falling back to HTMLVideoElement.`);
403
+ }
404
+ try {
405
+ await this.videos.getOrLoad(url, async () => {
406
+ const video = await loadVideo(url);
407
+ const texture = this.backend.createTexture(video);
408
+ return {
409
+ video,
410
+ frameSource: null,
411
+ texture,
412
+ lastUploadedTime: video.currentTime,
413
+ width: video.videoWidth,
414
+ height: video.videoHeight,
415
+ };
416
+ });
417
+ }
418
+ catch (err) {
419
+ getLogger().warn(`Failed to preload video ${url}:`, err instanceof Error ? err.message : String(err));
420
+ }
421
+ }
422
+ /** Whether a decodable video asset exists for this URL. */
423
+ hasVideoAsset(url) {
424
+ return this.videos.has(url);
425
+ }
426
+ /**
427
+ * Walk the element tree and collect every ACTIVE video element with
428
+ * the LOCAL clock value it should be evaluated at. Groups translate
429
+ * the clock (child times are group-relative) and, with time_remap
430
+ * (§5.8.4), WARP it — so nested videos inside speed-ramped/frozen/
431
+ * reversed groups decode the right frames. Fixes the former flat
432
+ * scan, which never updated videos nested in groups at all.
433
+ */
434
+ collectActiveVideos(elements, clock, parentDuration, out) {
435
+ for (const element of elements) {
436
+ const start = numberOrZero(element.time);
437
+ const dur = parseDuration(element.duration, parentDuration - start);
438
+ if (clock < start || clock > start + dur)
439
+ continue;
440
+ if (element.type === 'video') {
441
+ out.push({ el: element, clock });
442
+ }
443
+ else if (element.type === 'group') {
444
+ const group = element;
445
+ const local = clock - start;
446
+ const remap = timeRemapOf(group.time_remap);
447
+ const childClock = remap
448
+ ? Math.max(0, interpolateKeyframes(remap, local))
449
+ : local;
450
+ if (Array.isArray(group.elements)) {
451
+ this.collectActiveVideos(group.elements, childClock, dur, out);
452
+ }
453
+ }
454
+ }
455
+ }
456
+ /**
457
+ * Decode + upload the exact video frame each active frameSource-backed
458
+ * video element needs at composition time `time`. Await this before
459
+ * the synchronous `frame(time)` pass in contexts that use the
460
+ * WebCodecs path (the playback worker). Element-backed and externally
461
+ * pumped assets are untouched.
462
+ */
463
+ async prepareVideoFrames(time) {
464
+ const src = this.currentSource;
465
+ if (!src)
466
+ return;
467
+ const sourceDuration = typeof src.duration === 'number' ? src.duration : 0;
468
+ const active = [];
469
+ this.collectActiveVideos(src.elements, time, sourceDuration, active);
470
+ const uploads = [];
471
+ for (const { el, clock } of active) {
472
+ const url = String(el.source ?? '');
473
+ const asset = this.videos.get(url);
474
+ if (!asset?.frameSource)
475
+ continue;
476
+ const mediaTime = mapToMediaTime(clock, {
477
+ elementStart: numberOrZero(el.time),
478
+ trimStart: numberOrZero(el.trim_start),
479
+ trimDuration: trimDurationOf(el.trim_duration),
480
+ rate: rateOf(el.playback_rate),
481
+ loop: el.loop === true,
482
+ timeRemap: timeRemapOf(el.time_remap),
483
+ }, asset.frameSource.duration);
484
+ const frameSource = asset.frameSource;
485
+ uploads.push(frameSource.getFrame(mediaTime).then((frame) => {
486
+ if (!frame)
487
+ return;
488
+ if (asset.lastUploadedTime !== frame.timestamp) {
489
+ this.backend.updateTexture(asset.texture, frame);
490
+ asset.lastUploadedTime = frame.timestamp;
491
+ }
492
+ }));
493
+ }
494
+ if (uploads.length > 0)
495
+ await Promise.all(uploads);
496
+ }
497
+ /**
498
+ * Feed one decoded video frame for a source URL from outside the
499
+ * runtime. This is the worker-context video path: the host decodes
500
+ * with an HTMLVideoElement on the main thread, transfers ImageBitmaps
501
+ * here, and the video renderer samples the resulting texture exactly
502
+ * like an element-backed asset.
503
+ *
504
+ * The bitmap is uploaded immediately and then closed. If the media
505
+ * dimensions change (different bitmap size), the texture is recreated.
506
+ */
507
+ pushExternalVideoFrame(url, bitmap) {
508
+ if (!this.initialized) {
509
+ bitmap.close();
510
+ return;
511
+ }
512
+ const existing = this.videos.get(url);
513
+ if (existing &&
514
+ existing.width === bitmap.width &&
515
+ existing.height === bitmap.height) {
516
+ this.backend.updateTexture(existing.texture, bitmap);
517
+ bitmap.close();
518
+ return;
519
+ }
520
+ if (existing)
521
+ this.backend.destroyTexture(existing.texture);
522
+ const texture = this.backend.createTexture(bitmap);
523
+ this.videos.set(url, {
524
+ video: null,
525
+ frameSource: null,
526
+ texture,
527
+ lastUploadedTime: 0,
528
+ width: bitmap.width,
529
+ height: bitmap.height,
530
+ });
531
+ bitmap.close();
532
+ }
533
+ /** Render one frame at the given time. Synchronous. */
534
+ frame(time) {
535
+ if (!this.initialized)
536
+ return;
537
+ const src = this.currentSource;
538
+ if (!src)
539
+ return;
540
+ this.frameIndex++;
541
+ // Clear to the Source's background_color (default opaque black).
542
+ // This makes the schema field actually render — no more full-canvas
543
+ // backdrop rectangles as a workaround.
544
+ const clear = typeof src.background_color === 'string'
545
+ ? parseColorPremultiplied(src.background_color)
546
+ : this.transparentBackground
547
+ ? [0, 0, 0, 0]
548
+ : undefined;
549
+ const bloom = resolveBloom(src, time);
550
+ this.backend.beginFrame(clear);
551
+ if (bloom) {
552
+ // Render the scene into an offscreen target, then bloom → surface.
553
+ const w = this.backend.width, h = this.backend.height;
554
+ const scene = this.acquireBlurTarget(6, w, h);
555
+ this.backend.pushTarget(scene, (clear ?? [0, 0, 0, 0]));
556
+ renderSourceFrame(src, this.makeContext(time));
557
+ this.backend.popTarget();
558
+ this.applyBloom(scene, w, h, bloom);
559
+ }
560
+ else {
561
+ renderSourceFrame(src, this.makeContext(time));
562
+ }
563
+ this.backend.endFrame();
564
+ // Eviction runs AFTER endFrame() so WebGPU's queue.submit() has flushed
565
+ // before any gpuTexture.destroy() (no destroy-before-submit). WebGL has no
566
+ // submit and is trivially safe here.
567
+ this.evictGroupTargets();
568
+ }
569
+ /**
570
+ * Whole-frame bloom post (§4.8): extract pixels above the threshold,
571
+ * Gaussian-blur them, and add them back over the scene. Bright regions
572
+ * (specular highlights, emissive surfaces, bright media) bleed light;
573
+ * the amount is driven by each region's own brightness. Draws the final
574
+ * composite to the CURRENT target (the surface). Reuses blur slots 3/4.
575
+ */
576
+ applyBloom(scene, w, h, bloom) {
577
+ const bright = this.acquireBlurTarget(3, w, h);
578
+ const tmp = this.acquireBlurTarget(4, w, h);
579
+ const full = { cx: w / 2, cy: h / 2, width: w, height: h };
580
+ // Bright-pass: extract bright pixels into `bright`.
581
+ this.backend.pushTarget(bright, [0, 0, 0, 0]);
582
+ this.backend.drawStylizedQuad({ ...full, texture: scene.texture, mode: 'bloom_bright', p0: bloom.threshold, p1: bloom.knee });
583
+ this.backend.popTarget();
584
+ // Separable Gaussian: horizontal into tmp, vertical back into bright.
585
+ this.backend.pushTarget(tmp, [0, 0, 0, 0]);
586
+ this.backend.drawFilteredQuad({ ...full, texture: bright.texture, blurRadius: bloom.radius, blurDir: [1, 0], brightness: 1, contrast: 1, saturation: 1 });
587
+ this.backend.popTarget();
588
+ this.backend.pushTarget(bright, [0, 0, 0, 0]);
589
+ this.backend.drawFilteredQuad({ ...full, texture: tmp.texture, blurRadius: bloom.radius, blurDir: [0, 1], brightness: 1, contrast: 1, saturation: 1 });
590
+ this.backend.popTarget();
591
+ // Composite: the scene, then the blurred bright added × intensity.
592
+ const i = bloom.intensity;
593
+ this.backend.drawTexturedQuad({ ...full, rotation: 0, texture: scene.texture });
594
+ this.backend.drawTexturedQuad({ ...full, rotation: 0, texture: bright.texture, tint: [i, i, i, i], blend: 'add' });
595
+ }
596
+ /**
597
+ * Render one frame WITH source-level motion blur (§2.1) — the
598
+ * preview's "final quality" path, used when the playhead is parked.
599
+ * Renders N sub-frame samples into an offscreen layer and keeps a
600
+ * GPU running average (avg_k = avg_{k-1}·k/(k+1) + sample·1/(k+1)),
601
+ * then composites the result to the canvas. Within a few 8-bit
602
+ * rounding steps of the exporter's exact float average — preview
603
+ * parity, not the normative path. Falls back to frame() when the
604
+ * source has no motion blur. Video textures are decoded once at the
605
+ * frame time (call prepareVideoFrames first), not per sample —
606
+ * WebGPU executes the whole frame's draws at submit, so per-sample
607
+ * uploads would all show the last sample's pixels anyway.
608
+ */
609
+ renderFinalFrame(time, sampleOverride) {
610
+ if (!this.initialized)
611
+ return;
612
+ const src = this.currentSource;
613
+ if (!src)
614
+ return;
615
+ // Bump ONCE per output frame — all motion-blur sub-frame samples (each
616
+ // building its own makeContext) must share this frameIndex so they all
617
+ // stamp their shared pool entries as touched this frame.
618
+ this.frameIndex++;
619
+ const mb = src.motion_blur;
620
+ let samples = mb
621
+ ? Math.max(1, Math.min(32, Math.round(typeof mb.samples === 'number' ? mb.samples : 8)))
622
+ : 1;
623
+ // Live playback passes a budgeted sample count (≤ the source's) so
624
+ // blur stays visible at realtime cost; pause-refine and export use
625
+ // the full count.
626
+ if (sampleOverride !== undefined)
627
+ samples = Math.max(1, Math.min(samples, Math.round(sampleOverride)));
628
+ const shutter = mb && typeof mb.shutter === 'number'
629
+ ? Math.min(1, Math.max(0, mb.shutter))
630
+ : 0.5;
631
+ if (samples <= 1 || shutter <= 0) {
632
+ this.frame(time);
633
+ return;
634
+ }
635
+ const fps = src.frame_rate ?? 30;
636
+ const dur = typeof src.duration === 'number' ? src.duration : Number.POSITIVE_INFINITY;
637
+ const clear = typeof src.background_color === 'string'
638
+ ? parseColorPremultiplied(src.background_color)
639
+ : [0, 0, 0, 1];
640
+ const w = this.backend.width;
641
+ const h = this.backend.height;
642
+ const scene = this.acquireBlurTarget(0, w, h);
643
+ let avg = this.acquireBlurTarget(1, w, h);
644
+ let spare = this.acquireBlurTarget(2, w, h);
645
+ const full = { cx: w / 2, cy: h / 2, width: w, height: h, rotation: 0 };
646
+ this.backend.beginFrame(clear);
647
+ for (let k = 0; k < samples; k++) {
648
+ const tk = Math.min(Math.max(time + ((k + 0.5) / samples - 0.5) * (shutter / fps), 0), dur);
649
+ this.backend.pushTarget(scene, clear);
650
+ renderSourceFrame(src, this.makeContext(tk));
651
+ this.backend.popTarget();
652
+ if (k === 0) {
653
+ this.backend.pushTarget(avg, [0, 0, 0, 0]);
654
+ this.backend.drawTexturedQuad({ ...full, texture: scene.texture });
655
+ this.backend.popTarget();
656
+ }
657
+ else {
658
+ const a = 1 / (k + 1);
659
+ const keep = 1 - a;
660
+ this.backend.pushTarget(spare, [0, 0, 0, 0]);
661
+ this.backend.drawTexturedQuad({ ...full, texture: avg.texture, tint: [keep, keep, keep, keep] });
662
+ this.backend.drawTexturedQuad({ ...full, texture: scene.texture, tint: [a, a, a, a], blend: 'add' });
663
+ this.backend.popTarget();
664
+ [avg, spare] = [spare, avg];
665
+ }
666
+ }
667
+ const bloom = resolveBloom(src, time);
668
+ if (bloom) {
669
+ this.applyBloom(avg, w, h, bloom);
670
+ }
671
+ else {
672
+ this.backend.drawTexturedQuad({ ...full, texture: avg.texture });
673
+ }
674
+ this.backend.endFrame();
675
+ // After submit — safe to destroy evicted targets on both backends.
676
+ this.evictGroupTargets();
677
+ }
678
+ /** Resolve when all submitted GPU work has executed (see Backend.finish). */
679
+ async gpuFinish() {
680
+ if (!this.initialized)
681
+ return;
682
+ await this.backend.finish();
683
+ }
684
+ /**
685
+ * Set the soft cap (bytes) for the offscreen-FBO pool. ≤ 0 disables eviction
686
+ * (unbounded — the legacy behavior). Lets the cloud render Job tighten the cap
687
+ * on memory-constrained SwiftShader instances.
688
+ */
689
+ setGroupTargetPoolCap(bytes) {
690
+ this.groupTargetPoolCapBytes = bytes;
691
+ }
692
+ /**
693
+ * Frame-boundary LRU eviction of the groupTargets offscreen-FBO pool. MUST be
694
+ * called only AFTER backend.endFrame() (post queue.submit on WebGPU) so
695
+ * destroyRenderTarget()'s synchronous gpuTexture.destroy() can never race a
696
+ * not-yet-submitted command encoder. Entries touched THIS frame (lastTouched
697
+ * === this.frameIndex) are never candidates; stale entries are dropped
698
+ * least-recently-touched first until total pooled bytes (w*h*4) fall under the
699
+ * cap. Safe because every pooled target is fully rewritten (create-or-reuse →
700
+ * pushTarget+clear+draw) each frame it is used, so evicting an unused entry
701
+ * only costs a realloc next time. No double-destroy: delete() removes the
702
+ * entry, so the size-change path (group.ts/scene.ts) and dispose() never see
703
+ * it again. MUST stay STATELESS across calls (no cached candidate/untouched
704
+ * set) — frameIndex can advance >1 per output frame (per motion-blur sample +
705
+ * the renderFinalFrame→frame() fallback).
706
+ */
707
+ evictGroupTargets() {
708
+ const cap = this.groupTargetPoolCapBytes;
709
+ if (cap <= 0)
710
+ return;
711
+ let total = 0;
712
+ const candidates = [];
713
+ for (const [key, entry] of this.groupTargets.entries()) {
714
+ const bytes = entry.width * entry.height * 4;
715
+ total += bytes;
716
+ // Never evict an entry touched this frame.
717
+ if (entry.lastTouched !== this.frameIndex) {
718
+ candidates.push({ key, entry, bytes });
719
+ }
720
+ }
721
+ if (total <= cap)
722
+ return;
723
+ // LRU first: oldest lastTouched evicted first (undefined sorts oldest).
724
+ candidates.sort((a, b) => (a.entry.lastTouched ?? -1) - (b.entry.lastTouched ?? -1));
725
+ for (const c of candidates) {
726
+ if (total <= cap)
727
+ break;
728
+ this.backend.destroyRenderTarget(c.entry.target);
729
+ this.groupTargets.delete(c.key);
730
+ total -= c.bytes;
731
+ }
732
+ }
733
+ acquireBlurTarget(slot, width, height) {
734
+ const existing = this.blurTargets[slot];
735
+ if (existing && existing.width === width && existing.height === height) {
736
+ return existing.target;
737
+ }
738
+ if (existing)
739
+ this.backend.destroyRenderTarget(existing.target);
740
+ const target = this.backend.createRenderTarget(width, height);
741
+ this.blurTargets[slot] = { target, width, height };
742
+ return target;
743
+ }
744
+ /**
745
+ * Render one frame at the given time, awaiting any per-element async
746
+ * work (video seeking) first. Used by the exporter to render frames
747
+ * deterministically.
748
+ */
749
+ async renderAsync(source, time) {
750
+ if (!this.initialized)
751
+ return;
752
+ if (source !== this.currentSource)
753
+ this.load(source);
754
+ // Seek each active element-backed video to the right playhead.
755
+ const sourceDuration = typeof source.duration === 'number' ? source.duration : 0;
756
+ const seeks = [];
757
+ const activeSeek = [];
758
+ this.collectActiveVideos(source.elements, time, sourceDuration, activeSeek);
759
+ for (const { el, clock } of activeSeek) {
760
+ const url = String(el.source ?? '');
761
+ const asset = this.videos.get(url);
762
+ if (!asset || !asset.video)
763
+ continue;
764
+ const videoTime = mapToMediaTime(clock, {
765
+ elementStart: numberOrZero(el.time),
766
+ trimStart: numberOrZero(el.trim_start),
767
+ trimDuration: trimDurationOf(el.trim_duration),
768
+ rate: rateOf(el.playback_rate),
769
+ loop: el.loop === true,
770
+ timeRemap: timeRemapOf(el.time_remap),
771
+ }, Number.isFinite(asset.video.duration) ? asset.video.duration : 0);
772
+ seeks.push(seekVideo(asset.video, videoTime));
773
+ }
774
+ if (seeks.length > 0)
775
+ await Promise.all(seeks);
776
+ // FrameSource-backed videos decode their exact frame here.
777
+ await this.prepareVideoFrames(time);
778
+ this.frame(time);
779
+ }
780
+ /**
781
+ * Export to MP4. Renders frames serially through the same backend used
782
+ * for preview — the canvas visibly resizes during export. For an off-
783
+ * screen export, construct a separate ClipkitRuntime around an
784
+ * OffscreenCanvas at the desired dimensions.
785
+ */
786
+ /**
787
+ * Render each frame and stream its PNG (with alpha when `alpha`) to `onFrame`.
788
+ * The transparent export path — the PNGs are assembled by a server-side ffmpeg
789
+ * (ProRes 4444 / VP9-alpha). WebCodecs can't encode alpha, so this is the only
790
+ * route to transparent output.
791
+ */
792
+ /**
793
+ * Mix all of the loaded Source's audio (audio elements + video-embedded
794
+ * tracks) into one AudioBuffer for export. Shared by both export paths —
795
+ * the opaque MP4 path (export → WebCodecs muxer) and the transparent frame
796
+ * path (exportFrames → WAV → ffmpeg) — so they never diverge. Returns null
797
+ * when the comp has no audio (or the mix fails); the caller exports silently.
798
+ */
799
+ async mixExportAudio(src) {
800
+ const totalDuration = typeof src.duration === 'number' ? src.duration : 10;
801
+ const bufferMap = new Map();
802
+ for (const [url, buffer] of this.audioBuffers.entries())
803
+ bufferMap.set(url, buffer);
804
+ try {
805
+ return await mixSourceAudio(src, bufferMap, totalDuration);
806
+ }
807
+ catch (err) {
808
+ getLogger().warn('Audio mix failed; exporting video only:', err instanceof Error ? err.message : err);
809
+ return null;
810
+ }
811
+ }
812
+ async exportFrames(options) {
813
+ if (!this.initialized)
814
+ throw new Error('ClipkitRuntime.exportFrames() called before init()');
815
+ const src = this.currentSource;
816
+ if (!src)
817
+ throw new Error('No source loaded');
818
+ // Mix + emit audio first (the frame path can't mux it inline). Shares the
819
+ // exact mixing logic with export() via mixExportAudio so the two paths
820
+ // can't drift. Silent comps emit nothing — onAudio is simply not called.
821
+ if (options.onAudio) {
822
+ const mixed = await this.mixExportAudio(src);
823
+ if (mixed)
824
+ await options.onAudio(audioBufferToWav(mixed));
825
+ }
826
+ this.transparentBackground = options.alpha === true;
827
+ try {
828
+ const exporter = new ClipkitExporter(this.canvas, this);
829
+ return await exporter.exportFrames(src, options);
830
+ }
831
+ finally {
832
+ this.transparentBackground = false;
833
+ }
834
+ }
835
+ async export(options = {}) {
836
+ if (!this.initialized)
837
+ throw new Error('ClipkitRuntime.export() called before init()');
838
+ const src = this.currentSource;
839
+ if (!src)
840
+ throw new Error('No source loaded');
841
+ // Mix audio if the source has any audio elements.
842
+ let audioOptions = options.audio;
843
+ if (!audioOptions) {
844
+ const mixed = await this.mixExportAudio(src);
845
+ if (mixed)
846
+ audioOptions = { buffer: mixed };
847
+ }
848
+ // Resolve renderResolution → physical canvas dims + default bitrate.
849
+ // The backend gets resized so coordinate math stays in source units
850
+ // but the rasterized output is at the higher physical resolution.
851
+ const sourceWidth = src.width ?? 1920;
852
+ const sourceHeight = src.height ?? 1080;
853
+ const resolution = options.renderResolution ?? 'source';
854
+ const { pixelRatio, defaultBitrate, defaultCodec } = resolveRenderResolution(resolution, sourceWidth, sourceHeight);
855
+ const previousPhysW = this.backend.width * 1; // logical, but resize takes logical
856
+ const previousPhysH = this.backend.height * 1;
857
+ if (pixelRatio !== 1) {
858
+ getLogger().info(`Export at ${resolution}: scaling backend to ${Math.round(sourceWidth * pixelRatio)}×${Math.round(sourceHeight * pixelRatio)} (pixelRatio=${pixelRatio.toFixed(3)})`);
859
+ this.backend.resize(sourceWidth, sourceHeight, pixelRatio);
860
+ }
861
+ try {
862
+ const exporter = new ClipkitExporter(this.canvas, this);
863
+ return await exporter.export(src, {
864
+ codec: defaultCodec,
865
+ bitrate: defaultBitrate,
866
+ ...options,
867
+ audio: audioOptions,
868
+ });
869
+ }
870
+ finally {
871
+ // Restore the backend to logical 1× so subsequent preview frames
872
+ // don't render into a 4× backing store.
873
+ if (pixelRatio !== 1) {
874
+ this.backend.resize(previousPhysW, previousPhysH);
875
+ }
876
+ }
877
+ }
878
+ /** Release all GPU resources. After this the runtime is unusable. */
879
+ dispose() {
880
+ if (this.disposed)
881
+ return;
882
+ this.disposed = true;
883
+ // Free textures via destroyTexture? No — dispose() on the backend
884
+ // releases the device, which cascades through all textures.
885
+ this.images.clear();
886
+ for (const [, asset] of this.videos.entries())
887
+ asset.frameSource?.dispose();
888
+ this.videos.clear();
889
+ this.fontAtlases.clear();
890
+ this.svgRasters.clear();
891
+ this.maskedTexts.clear();
892
+ for (const [, t] of this.groupTargets.entries())
893
+ this.backend.destroyRenderTarget(t.target);
894
+ this.groupTargets.clear();
895
+ for (const t of this.blurTargets)
896
+ if (t)
897
+ this.backend.destroyRenderTarget(t.target);
898
+ this.blurTargets = [];
899
+ this.luts.clear();
900
+ this.backend.dispose();
901
+ }
902
+ makeContext(time) {
903
+ const src = this.currentSource;
904
+ const sourceDuration = src && typeof src.duration === 'number' ? src.duration : 0;
905
+ const canvas = { width: this.backend.width, height: this.backend.height };
906
+ // §4.4.2: the scene camera IS the root model matrix. Its xy block is
907
+ // identity and z = 0 content keeps w = 1, so flat content renders
908
+ // exactly as without a camera; layers reset to identity (§4.4.3).
909
+ const rootMatrix = src?.camera ? cameraMatrix(src.camera, time, canvas) : MAT4_IDENTITY;
910
+ // §4.4.3: `z` orders ALWAYS (no camera ⇒ pure stacking, camera ⇒
911
+ // stacking + perspective), `track` is the tiebreak. So the depth
912
+ // sort runs unless the author opted into fixed paint order via
913
+ // `camera.sort: 'paint'`. Perf guard: a doc with NO camera and NO
914
+ // depth fields anywhere can't reorder (all z = 0 ⇒ track order), so
915
+ // skip the sort entirely — keeps the 2D / huge-track path cheap and
916
+ // byte-identical.
917
+ const depthSort = src?.camera
918
+ ? src.camera.sort !== 'paint'
919
+ : !!src && anyElementHasDepth(src.elements);
920
+ // §4.8 lighting: resolve scene lights + the camera eye. Empty lights
921
+ // ⇒ nothing is shaded (unlit, byte-identical).
922
+ const lights = src ? resolveLights(src, time) : [];
923
+ const environment = src ? resolveEnvironment(src, time) : null;
924
+ // Image env: substitute the cached average color (from the loaded
925
+ // equirect pixels) for the roughness-blurred reflection fallback.
926
+ if (environment?.image) {
927
+ const avg = this.envAvg.get(environment.image);
928
+ if (avg)
929
+ environment.avg = [avg[0], avg[1], avg[2]];
930
+ }
931
+ const eye = src?.camera
932
+ ? cameraEyeWorld(src.camera, time, canvas)
933
+ : [canvas.width / 2, canvas.height / 2, 1000];
934
+ return {
935
+ backend: this.backend,
936
+ canvas,
937
+ time,
938
+ sourceDuration,
939
+ frameIndex: this.frameIndex,
940
+ images: this.images,
941
+ videos: this.videos,
942
+ fontAtlases: this.fontAtlases,
943
+ svgRasters: this.svgRasters,
944
+ maskedTexts: this.maskedTexts,
945
+ groupTargets: this.groupTargets,
946
+ luts: this.luts,
947
+ modelMatrix: rootMatrix,
948
+ worldMatrix: MAT4_IDENTITY,
949
+ lights,
950
+ eye,
951
+ environment,
952
+ depthSort,
953
+ opacityFactor: 1,
954
+ timeOffset: 0,
955
+ surfaceWidth: this.backend.width,
956
+ surfaceHeight: this.backend.height,
957
+ };
958
+ }
959
+ }
960
+ // ─── Local helpers (mirrors of compositor/scene.ts; kept private to avoid
961
+ // a circular import) ─────────────────────────────────────────────────────────
962
+ /**
963
+ * True if any element in the tree carries a depth field (`z`, `x_rotation`,
964
+ * `y_rotation`, or a 3D position path) — the perf guard for the no-camera
965
+ * depth sort. `z_rotation` is in-plane (2D) and does NOT count. Mirrors the
966
+ * `has` check in resolve.ts `resolve3D`.
967
+ */
968
+ function anyElementHasDepth(elements) {
969
+ for (const raw of elements) {
970
+ const el = raw;
971
+ if (el.z !== undefined || el.x_rotation !== undefined || el.y_rotation !== undefined)
972
+ return true;
973
+ if (el.keyframe_animations?.some((k) => k.property === 'z' || k.property === 'x_rotation' || k.property === 'y_rotation' ||
974
+ (k.property === 'position' &&
975
+ k.keyframes.some((kf) => Array.isArray(kf.value) && kf.value.length === 3)))) {
976
+ return true;
977
+ }
978
+ if (el.type === 'group' && Array.isArray(el.elements) && anyElementHasDepth(el.elements))
979
+ return true;
980
+ if (el.mask?.elements && anyElementHasDepth(el.mask.elements))
981
+ return true;
982
+ }
983
+ return false;
984
+ }
985
+ function isActiveAt(element, time, sourceDuration) {
986
+ const start = numberOrZero(element.time);
987
+ const dur = parseDuration(element.duration, sourceDuration - start);
988
+ return time >= start && time <= start + dur;
989
+ }
990
+ function numberOrZero(v) {
991
+ if (typeof v === 'number' && Number.isFinite(v))
992
+ return v;
993
+ if (typeof v === 'string') {
994
+ const n = parseFloat(v);
995
+ return Number.isFinite(n) ? n : 0;
996
+ }
997
+ return 0;
998
+ }
999
+ function parseDuration(v, fallback) {
1000
+ if (v === 'auto' || v === 'end' || v == null)
1001
+ return fallback;
1002
+ if (typeof v === 'number' && Number.isFinite(v))
1003
+ return v;
1004
+ if (typeof v === 'string') {
1005
+ const n = parseFloat(v);
1006
+ return Number.isFinite(n) ? n : fallback;
1007
+ }
1008
+ return fallback;
1009
+ }
1010
+ // Mark unused logger import so tree-shakers don't complain.
1011
+ void getLogger;
1012
+ //# sourceMappingURL=runtime.js.map