@mcut/compositor 0.1.0-alpha.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.
@@ -0,0 +1,507 @@
1
+ import { AssetId, BlendMode, CaptionStyle, Effect, Project, TextBox, TextRun, TextStyle, TimelineElement, Track, Transform, TransitionPair } from "@mcut/timeline";
2
+
3
+ //#region src/geometry.d.ts
4
+ /**
5
+ * Element coordinates are center-origin: (0, 0) is the canvas center and the
6
+ * element is anchored at its own center. This converts to canvas pixels.
7
+ */
8
+ declare function toCanvasPoint(project: Project, x: number, y: number): {
9
+ x: number;
10
+ y: number;
11
+ };
12
+ declare function fromCanvasPoint(project: Project, x: number, y: number): {
13
+ x: number;
14
+ y: number;
15
+ };
16
+ /** Oriented bounding box in canvas pixels. Rotation in degrees, clockwise. */
17
+ interface OBB {
18
+ cx: number;
19
+ cy: number;
20
+ width: number;
21
+ height: number;
22
+ rotation: number;
23
+ }
24
+ interface ElementSize {
25
+ width: number;
26
+ height: number;
27
+ }
28
+ declare const degToRad: (deg: number) => number;
29
+ interface SizeHelpers {
30
+ /** Natural pixel size of a media asset (probed metadata). */
31
+ getAssetSize?: (assetId: string) => ElementSize | null;
32
+ /** Measure a text block (unscaled). Required for text element bounds. */
33
+ measureText?: (text: string, style: TextStyle, box?: TextBox, runs?: readonly TextRun[]) => ElementSize;
34
+ }
35
+ declare function getElementNaturalSize(element: TimelineElement, helpers?: SizeHelpers): ElementSize | null;
36
+ declare function getElementDisplaySize(element: TimelineElement, helpers?: SizeHelpers): ElementSize | null;
37
+ interface DisplaySizePatch {
38
+ width?: number;
39
+ height?: number;
40
+ preserveAspect?: boolean;
41
+ }
42
+ declare function getTransformForDisplaySize(transform: Transform, natural: ElementSize, patch: DisplaySizePatch): Transform;
43
+ /**
44
+ * The element's oriented bounding box on the canvas, or `null` when its size
45
+ * is unknown (e.g. unprobed media without a frame yet). Captions are
46
+ * positioned by their style band and are not transformable; they have no OBB.
47
+ */
48
+ declare function getElementOBB(project: Project, element: TimelineElement, helpers?: SizeHelpers): OBB | null;
49
+ /** Is the canvas-space point inside the (rotated) box? */
50
+ declare function hitTestOBB(obb: OBB, x: number, y: number): boolean;
51
+ type HandleId = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'rotate';
52
+ interface Handle {
53
+ id: HandleId;
54
+ /** Canvas-space position. */
55
+ x: number;
56
+ y: number;
57
+ }
58
+ /** Distance of the rotate handle above the box's top edge (canvas px). */
59
+ declare const ROTATE_HANDLE_OFFSET = 32;
60
+ /** The 8 resize handles plus the rotate handle, in canvas space. */
61
+ declare function getHandles(obb: OBB): Handle[];
62
+ /** Hit-test the handles (square hit area of `size` px around each). */
63
+ declare function hitTestHandles(obb: OBB, x: number, y: number, size?: number): HandleId | null;
64
+ /**
65
+ * "Contain" scale factor for fitting a `width`×`height` media into the
66
+ * project frame (used when inserting media elements).
67
+ */
68
+ declare function getFitScale(project: Project, width: number, height: number): number;
69
+ //#endregion
70
+ //#region src/types.d.ts
71
+ /** A 2D context we can composite into (on- or off-screen). */
72
+ type Canvas2D = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
73
+ /**
74
+ * Supplies pixel data for media assets at a given source time. The preview
75
+ * implementation is allowed to be approximate (pooled `<video>` elements);
76
+ * the export implementation must be exact (decoded samples). Keeping this
77
+ * behind an interface is what lets one compositor serve both.
78
+ */
79
+ interface FrameSource {
80
+ /**
81
+ * Image for `assetId` at `sourceTimeMs` (media-local time, after trim).
82
+ * Return `null` when no frame is available yet; the compositor skips it.
83
+ */
84
+ getFrame(assetId: AssetId, sourceTimeMs: number): CanvasImageSource | null;
85
+ }
86
+ interface RenderFrameOptions {
87
+ /** Pixel source for video/image assets. Omit to render only vector elements. */
88
+ source?: FrameSource;
89
+ /** Canvas background. Default black. */
90
+ backgroundColor?: string;
91
+ /**
92
+ * Elements to leave out of the frame — e.g. a text element while a DOM
93
+ * inline editor is overlaid on it (the editor IS its WYSIWYG render).
94
+ */
95
+ skipElementIds?: ReadonlySet<string>;
96
+ /**
97
+ * Sub-frame passes for per-element motion blur (see `motion-blur.ts`).
98
+ * Default 8; export passes 16.
99
+ */
100
+ motionBlurSamples?: number;
101
+ /**
102
+ * Scratch surface factory for motion-blur accumulation; the context is
103
+ * cleared before each use. Defaults to a cached `OffscreenCanvas` —
104
+ * environments without one (and tests) inject this. Return null to
105
+ * disable motion blur.
106
+ */
107
+ createScratchContext?: (width: number, height: number) => Canvas2D | null;
108
+ }
109
+ interface ElementRenderContext {
110
+ /**
111
+ * Frame-space canvas2d context for raster drawing (text, chrome, custom
112
+ * renderers). On the canvas2d backend this is the target itself; on GPU
113
+ * backends it is a scratch surface composited in z-order. Reading it marks
114
+ * the raster surface in use — renderers with a structured fast path (video
115
+ * /image quads) should draw via `backend` instead.
116
+ */
117
+ ctx: Canvas2D;
118
+ /** The draw layer (see backend.ts) — structured fast paths live here. */
119
+ backend: RenderBackend;
120
+ project: Project;
121
+ track: Track;
122
+ /** Absolute timeline time being rendered. */
123
+ timeMs: number;
124
+ source: FrameSource | undefined;
125
+ }
126
+ type ElementRenderer<E extends TimelineElement = TimelineElement> = (element: E, context: ElementRenderContext) => void;
127
+ //#endregion
128
+ //#region src/backend.d.ts
129
+ /**
130
+ * The draw layer renderFrame composites through. The track/element walk,
131
+ * transition pairing, and geometry in render-frame.ts are renderer-agnostic;
132
+ * backends own how pixels actually land:
133
+ *
134
+ * - {@link Canvas2DBackend} — the original canvas2d path. The reference
135
+ * implementation: tests run against it (FakeContext2D), and headless/Node
136
+ * environments use it. Not a runtime fallback once WebGPU is the default.
137
+ * - WebGPUBackend (webgpu/) — image quads composite as textured passes with
138
+ * WGSL effects; raster content (text, captions, multicam chrome, custom
139
+ * renderers) still paints through canvas2d in frame space and uploads as
140
+ * a texture layer, preserving output across backends.
141
+ *
142
+ * Determinism contract: for a given backend, the same project, time, and
143
+ * frame source produce the same pixels in preview and export. Across
144
+ * backends, output is perceptually identical (GPU float math differs at the
145
+ * ULP level), so golden tests compare with tolerance, not byte equality.
146
+ */
147
+ /** Resolved element chrome: transform in frame coords + compositing state. */
148
+ interface LayerChrome {
149
+ /** Element center in canvas coordinates. */
150
+ centerX: number;
151
+ centerY: number;
152
+ rotationDeg: number;
153
+ scaleX: number;
154
+ scaleY: number;
155
+ /** 0..1, multiplied into the layer. */
156
+ opacity: number;
157
+ blendMode?: BlendMode | undefined;
158
+ effects?: readonly Effect[] | undefined;
159
+ }
160
+ /** A plain media draw: one image into a centered rect, optional crop/radius. */
161
+ interface ImageQuad {
162
+ image: CanvasImageSource;
163
+ /** Source crop rect in image pixels; null draws the whole image. */
164
+ src: {
165
+ sx: number;
166
+ sy: number;
167
+ sw: number;
168
+ sh: number;
169
+ } | null;
170
+ /** Destination size, centered on the chrome's origin. */
171
+ dw: number;
172
+ dh: number;
173
+ /** Rounded-corner radius in destination pixels (0 = sharp). */
174
+ cornerRadius: number;
175
+ }
176
+ interface RenderBackend {
177
+ readonly kind: 'canvas2d' | 'webgpu' | (string & {});
178
+ readonly width: number;
179
+ readonly height: number;
180
+ beginFrame(backgroundColor: string): void;
181
+ endFrame(): void;
182
+ /**
183
+ * The frame-space canvas2d context for raster content. Acquiring it marks
184
+ * the raster surface dirty so the backend composites it in z-order;
185
+ * renderers that only need it for text measurement still go through here
186
+ * (text rasterizes anyway).
187
+ */
188
+ acquireRaster(): Canvas2D;
189
+ /**
190
+ * Composite a media quad with chrome — the GPU fast path. Backends without
191
+ * one (or layers a backend cannot run, e.g. raw `css` filters on WebGPU)
192
+ * draw it through the raster context instead.
193
+ */
194
+ drawImageQuad(quad: ImageQuad, chrome: LayerChrome): void;
195
+ /**
196
+ * While a raster scope is open, every draw — including image quads — lands
197
+ * on the raster context, so canvas2d state (clips, alpha, transforms) set
198
+ * by the caller applies. Transition mixes and motion-blur accumulation
199
+ * need this. Scopes nest.
200
+ */
201
+ pushRasterScope(): void;
202
+ popRasterScope(): void;
203
+ }
204
+ /**
205
+ * Transform + opacity + effect-stack filter + blend mode around a canvas2d
206
+ * draw (the original withTransform). `ctx.filter` is unsupported in some
207
+ * older engines; there the stack degrades to an unfiltered render rather
208
+ * than failing.
209
+ */
210
+ declare function applyChrome(ctx: Canvas2D, chrome: LayerChrome, draw: () => void): void;
211
+ /** Draw an {@link ImageQuad} in local (chrome-applied) coordinates. */
212
+ declare function drawImageQuad2D(ctx: Canvas2D, quad: ImageQuad): void;
213
+ /** The canvas2d backend: draws straight into the target context. */
214
+ declare class Canvas2DBackend implements RenderBackend {
215
+ private readonly ctx;
216
+ readonly width: number;
217
+ readonly height: number;
218
+ readonly kind = "canvas2d";
219
+ constructor(ctx: Canvas2D, width: number, height: number);
220
+ beginFrame(backgroundColor: string): void;
221
+ endFrame(): void;
222
+ acquireRaster(): Canvas2D;
223
+ drawImageQuad(quad: ImageQuad, chrome: LayerChrome): void;
224
+ pushRasterScope(): void;
225
+ popRasterScope(): void;
226
+ }
227
+ /**
228
+ * Build the per-element render context. `ctx` is a getter so backends learn
229
+ * when raster content is actually being drawn (GPU backends flush the
230
+ * raster surface lazily, in z-order).
231
+ */
232
+ declare function createElementContext(backend: RenderBackend, project: Project, track: Track, timeMs: number, source: FrameSource | undefined): ElementRenderContext;
233
+ //#endregion
234
+ //#region src/render-frame.d.ts
235
+ /**
236
+ * Render one frame of `project` at `timeMs` into `ctx` (canvas2d path).
237
+ *
238
+ * Pure with respect to inputs: the same project, time, and frame source
239
+ * produce the same pixels — which is what makes the export path
240
+ * deterministic. The context is expected to be in project coordinates
241
+ * (`project.width` × `project.height`); callers rendering at other sizes
242
+ * apply their own transform before calling.
243
+ */
244
+ declare function renderFrame(ctx: Canvas2D, project: Project, timeMs: number, options?: RenderFrameOptions): void;
245
+ /**
246
+ * Render one frame through an explicit {@link RenderBackend}. The
247
+ * track/element walk, transition pairing, and animation resolution here are
248
+ * backend-agnostic; only the draws differ.
249
+ *
250
+ * Tracks render bottom-up (index 0 first), elements in start order. Clips
251
+ * joined by a transition render through the blend instead of the normal
252
+ * pass while the window (centered on their cut) is active.
253
+ */
254
+ declare function renderFrameWith(backend: RenderBackend, project: Project, timeMs: number, options?: RenderFrameOptions): void;
255
+ //#endregion
256
+ //#region src/gpu-effects.d.ts
257
+ /** Idempotent; the compositor registers these at module load. */
258
+ declare function registerGpuEffectTypes(): void;
259
+ //#endregion
260
+ //#region src/webgpu/webgpu-backend.d.ts
261
+ /**
262
+ * The WebGPU compositor backend. Image quads (video/image elements)
263
+ * composite as full-frame passes over a ping-pong texture pair —
264
+ * `importExternalTexture` keeps `VideoFrame`s zero-copy — with effects as
265
+ * WGSL passes and every blend mode in one shader (mode uniform). Raster
266
+ * content (text, captions, multicam chrome, transitions, custom renderers)
267
+ * still paints through canvas2d in frame space and uploads as a texture
268
+ * layer in z-order, so output matches the reference Canvas2D backend.
269
+ *
270
+ * Lifecycle: `WebGPUBackend.create({ canvas, width, height })` once, then
271
+ * `renderFrameWith(backend, project, timeMs, options)` per frame, `resize`
272
+ * on project dimension changes, `dispose` when done. For export, pass the
273
+ * GPUTexture-backed canvas straight to Mediabunny's CanvasSource — no CPU
274
+ * readback.
275
+ */
276
+ interface WebGPUBackendOptions {
277
+ /** Presentation canvas; the backend configures its 'webgpu' context. */
278
+ canvas: HTMLCanvasElement | OffscreenCanvas;
279
+ /** Project (composition) size — passes render at this resolution. */
280
+ width: number;
281
+ height: number;
282
+ /** Bring your own device (tests/sharing); otherwise adapter-requested. */
283
+ device?: GPUDevice;
284
+ }
285
+ /** Whether this runtime exposes WebGPU at all (Chrome 113+/Safari 26+/Firefox 141+). */
286
+ declare function isWebGPUSupported(): boolean;
287
+ declare class WebGPUBackend implements RenderBackend {
288
+ readonly kind = "webgpu";
289
+ readonly width: number;
290
+ readonly height: number;
291
+ private readonly device;
292
+ private readonly context;
293
+ private readonly presentationFormat;
294
+ private acc;
295
+ private accIndex;
296
+ private readonly rasterCanvas;
297
+ private readonly rasterCtx;
298
+ private rasterDirty;
299
+ private rasterScope;
300
+ private readonly sampler;
301
+ private readonly pipelines;
302
+ private readonly texturePool;
303
+ private frameBuffers;
304
+ private readonly identityCurves;
305
+ private readonly luts;
306
+ static create(options: WebGPUBackendOptions): Promise<WebGPUBackend>;
307
+ private constructor();
308
+ /**
309
+ * Register a 3D LUT for `lut3d` effects: `data` is size³ RGB triples
310
+ * (0..1, red fastest), flattened to a (size² × size) 2D texture.
311
+ */
312
+ registerLut3D(lutId: string, size: number, data: Float32Array): void;
313
+ beginFrame(backgroundColor: string): void;
314
+ endFrame(): void;
315
+ acquireRaster(): Canvas2D;
316
+ pushRasterScope(): void;
317
+ popRasterScope(): void;
318
+ drawImageQuad(quad: ImageQuad, chrome: LayerChrome): void;
319
+ /** Free GPU resources. The backend is unusable afterwards. */
320
+ dispose(): void;
321
+ private createAccTexture;
322
+ private acquireTexture;
323
+ private releaseTexture;
324
+ private uniformBuffer;
325
+ private storageBuffer;
326
+ /** Upload the raster scratch and composite it as a normal full-frame layer. */
327
+ private flushRaster;
328
+ private drawQuadOnGpu;
329
+ /** Sample (and crop) the source into a fresh layer texture. */
330
+ private prepareLayer;
331
+ private runEffectPass;
332
+ private runColorPass;
333
+ private runBlurPasses;
334
+ private compositeFullFrame;
335
+ private renderFullscreen;
336
+ }
337
+ //#endregion
338
+ //#region src/webgpu/effect-plan.d.ts
339
+ interface ColorOp {
340
+ kind: number;
341
+ /** Up to 8 packed params, op-specific (vec4 a + vec4 b in the shader). */
342
+ params: number[];
343
+ }
344
+ type EffectPass = {
345
+ kind: 'color';
346
+ ops: ColorOp[]; /** Per-channel 256-entry LUTs when a curves op is in this run. */
347
+ curves: {
348
+ r: Float32Array;
349
+ g: Float32Array;
350
+ b: Float32Array;
351
+ } | null;
352
+ } | {
353
+ kind: 'blur';
354
+ radius: number;
355
+ } | {
356
+ kind: 'shadow';
357
+ offsetX: number;
358
+ offsetY: number;
359
+ blur: number;
360
+ color: [number, number, number, number];
361
+ } | {
362
+ kind: 'lut3d';
363
+ lutId: string;
364
+ intensity: number;
365
+ };
366
+ interface EffectPlan {
367
+ passes: EffectPass[];
368
+ /** The stack contains effects only canvas2d can run (`css`/unknown). */
369
+ unsupported: boolean;
370
+ }
371
+ interface CurvePoint {
372
+ x: number;
373
+ y: number;
374
+ }
375
+ /** Monotone-x piecewise-linear curve → 256-entry LUT (identity when empty). */
376
+ declare function curveToLut(points: readonly CurvePoint[] | undefined): Float32Array;
377
+ declare function planEffects(effects: readonly Effect[] | undefined): EffectPlan;
378
+ /** True when this stack can only render through the canvas2d raster path. */
379
+ declare function hasUnsupportedEffects(effects: readonly Effect[] | undefined): boolean;
380
+ //#endregion
381
+ //#region src/transition-renderers.d.ts
382
+ /**
383
+ * The renderer half of the transition registry (pair it with
384
+ * registerTransitionType in @mcut/timeline). A transition is a pure mixer:
385
+ * given draw thunks for both sides of a cut and the blend completion, paint
386
+ * the in-between frame. The same renderers serve clip-to-clip transitions
387
+ * (render-frame.ts) and multicam angle-cut transitions (renderers.ts).
388
+ */
389
+ /** Everything a transition renderer needs to blend its pair. */
390
+ interface TransitionRenderContext {
391
+ ctx: Canvas2D;
392
+ project: Project;
393
+ pair: TransitionPair;
394
+ timeMs: number;
395
+ /** Blend completion 0→1 across the window (0.5 at the cut). */
396
+ completion: number;
397
+ /** Draw the outgoing clip (already extended past its out point). */
398
+ drawLeft: () => void;
399
+ /** Draw the incoming clip (already pre-rolling before its in point). */
400
+ drawRight: () => void;
401
+ }
402
+ type TransitionRenderer = (context: TransitionRenderContext) => void;
403
+ /**
404
+ * Register the renderer half of a transition type. Built-ins register below
405
+ * through the same call; re-registering overrides (e.g. to restyle a
406
+ * built-in wipe).
407
+ */
408
+ declare function registerTransitionRenderer(type: string, renderer: TransitionRenderer): void;
409
+ /** The registered renderer for `type`, or undefined (degrade to a hard cut). */
410
+ declare function getTransitionRenderer(type: string): TransitionRenderer | undefined;
411
+ //#endregion
412
+ //#region src/text.d.ts
413
+ /**
414
+ * Width of `text` drawn with `font` and `letterSpacingPx` of tracking.
415
+ * Implementations should set `ctx.letterSpacing` when the engine supports it
416
+ * so measurement matches drawing; engines without it ignore the parameter
417
+ * (and the renderer draws without tracking — consistent both ways).
418
+ */
419
+ type MeasureFn = (text: string, font: string, letterSpacingPx?: number) => number;
420
+ declare function buildFont(style: {
421
+ fontStyle?: 'normal' | 'italic';
422
+ fontWeight: number;
423
+ fontSize: number;
424
+ fontFamily: string;
425
+ }): string;
426
+ /** Render-time case transform; the stored text keeps the user's casing. */
427
+ declare function applyTextTransform(text: string, transform: TextStyle['textTransform']): string;
428
+ /** One same-style stretch of a visual line (rich-text runs; see rich-text.ts). */
429
+ interface TextSegment {
430
+ /** Case-transformed slice, ready to paint. */
431
+ text: string;
432
+ width: number;
433
+ font: string;
434
+ /** Fill override from the segment's run (base style color otherwise). */
435
+ color?: string;
436
+ }
437
+ interface TextBlockLayout {
438
+ /** `segments` present only when the element has style runs. */
439
+ lines: {
440
+ text: string;
441
+ width: number;
442
+ segments?: TextSegment[];
443
+ }[];
444
+ font: string;
445
+ lineHeight: number;
446
+ padding: number;
447
+ overflow: TextBox['overflow'] | null;
448
+ /** Box size including padding — matches what the renderer draws. */
449
+ width: number;
450
+ height: number;
451
+ }
452
+ interface TextBlockOptions {
453
+ box?: TextBox;
454
+ /** Per-range style overrides (character offsets into the text). */
455
+ runs?: readonly TextRun[];
456
+ }
457
+ /**
458
+ * Lay out a (possibly multi-line) text element. Lines come from explicit
459
+ * newlines only unless a text box width is provided.
460
+ */
461
+ declare function layoutTextBlock(measure: MeasureFn, text: string, style: TextStyle, options?: TextBlockOptions): TextBlockLayout;
462
+ interface CaptionWordBox {
463
+ text: string;
464
+ /** X offset from the line's left edge. */
465
+ x: number;
466
+ width: number;
467
+ /** Word timing relative to the element start, when known. */
468
+ startMs?: number;
469
+ endMs?: number;
470
+ }
471
+ interface CaptionLayout {
472
+ lines: {
473
+ words: CaptionWordBox[];
474
+ width: number;
475
+ }[];
476
+ font: string;
477
+ lineHeight: number;
478
+ spaceWidth: number;
479
+ }
480
+ /**
481
+ * Greedily wrap caption words into centered lines no wider than `maxWidth`.
482
+ * Falls back to whitespace-split text when word timings are absent.
483
+ */
484
+ declare function layoutCaption(measure: MeasureFn, element: {
485
+ text: string;
486
+ words?: {
487
+ text: string;
488
+ startMs: number;
489
+ endMs: number;
490
+ }[];
491
+ }, style: CaptionStyle, maxWidth: number): CaptionLayout;
492
+ //#endregion
493
+ //#region src/renderers.d.ts
494
+ /**
495
+ * Register a renderer for an element type. Built-in types can be overridden;
496
+ * custom element types (added via custom commands) plug in here — the
497
+ * compositor side of the engine's command registry.
498
+ */
499
+ declare function registerElementRenderer<E extends TimelineElement>(type: E['type'] | (string & {}), renderer: ElementRenderer<E>): void;
500
+ declare function getElementRenderer(type: string): ElementRenderer | undefined;
501
+ declare function measureWith(ctx: Canvas2D): MeasureFn;
502
+ declare function getImageSize(source: CanvasImageSource): {
503
+ width: number;
504
+ height: number;
505
+ };
506
+ //#endregion
507
+ export { type Canvas2D, Canvas2DBackend, type CaptionLayout, type CaptionWordBox, type ColorOp, type DisplaySizePatch, type EffectPass, type EffectPlan, type ElementRenderContext, type ElementRenderer, type ElementSize, type FrameSource, type Handle, type HandleId, type ImageQuad, type LayerChrome, type MeasureFn, type OBB, ROTATE_HANDLE_OFFSET, type RenderBackend, type RenderFrameOptions, type SizeHelpers, type TextBlockLayout, type TransitionRenderContext, type TransitionRenderer, WebGPUBackend, type WebGPUBackendOptions, applyChrome, applyTextTransform, buildFont, createElementContext, curveToLut, degToRad, drawImageQuad2D, fromCanvasPoint, getElementDisplaySize, getElementNaturalSize, getElementOBB, getElementRenderer, getFitScale, getHandles, getImageSize, getTransformForDisplaySize, getTransitionRenderer, hasUnsupportedEffects, hitTestHandles, hitTestOBB, isWebGPUSupported, layoutCaption, layoutTextBlock, measureWith, planEffects, registerElementRenderer, registerGpuEffectTypes, registerTransitionRenderer, renderFrame, renderFrameWith, toCanvasPoint };