@sequio/server 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.
package/dist/index.js ADDED
@@ -0,0 +1,166 @@
1
+ import { TextClip as g, ShapeClip as w, ImageSource as y, ImageClip as b, VideoSource as I, VideoClip as v, BlurEffect as S, ColorEffect as x, Timebase as k, Compositor as C, VisualTrack as O, AudioEngine as p, AudioSource as z, AudioClip as E, easeInOutCubic as F, easeOutCubic as T, easeInCubic as A, easeInOutQuad as H, easeOutQuad as M, easeInQuad as W, hold as Q, linear as R, fonts as d } from "@sequio/engine";
2
+ const V = {
3
+ linear: R,
4
+ hold: Q,
5
+ easeInQuad: W,
6
+ easeOutQuad: M,
7
+ easeInOutQuad: H,
8
+ easeInCubic: A,
9
+ easeOutCubic: T,
10
+ easeInOutCubic: F
11
+ };
12
+ function s(t, e) {
13
+ e !== void 0 && (typeof e == "object" && e !== null && "keyframes" in e ? t.setKeyframes(e.keyframes.map((o) => ({ time: o.time, value: o.value, easing: o.easing ? V[o.easing] : void 0 }))) : t.setStatic(e));
14
+ }
15
+ function c(t) {
16
+ if (t.type === "blur") {
17
+ const o = new S();
18
+ return t.strength !== void 0 && o.strength.setStatic(t.strength), o;
19
+ }
20
+ const e = new x();
21
+ return t.brightness !== void 0 && e.brightness.setStatic(t.brightness), t.contrast !== void 0 && e.contrast.setStatic(t.contrast), t.saturation !== void 0 && e.saturation.setStatic(t.saturation), e;
22
+ }
23
+ function B(t, e) {
24
+ t.start = e.start, t.end = e.end, e.sourceIn !== void 0 && (t.sourceIn = e.sourceIn), e.sourceOut !== void 0 && (t.sourceOut = e.sourceOut), e.speed !== void 0 && (t.speed = e.speed), e.blendMode && (t.blendMode = e.blendMode), s(t.opacity, e.opacity);
25
+ const o = e.transform;
26
+ o && (o.anchor && t.transform.anchor.setStatic(o.anchor), s(t.transform.position, o.position), s(t.transform.scale, o.scale), s(t.transform.rotation, o.rotation));
27
+ for (const i of e.effects ?? []) t.effects.push(c(i));
28
+ }
29
+ async function G(t, e) {
30
+ let o;
31
+ if (t.type === "text")
32
+ o = new g({ text: t.text, fontFamily: t.fontFamily, fill: t.fill }), s(o.fontSize, t.fontSize);
33
+ else if (t.type === "shape")
34
+ o = new w(t.shape);
35
+ else if (t.type === "image") {
36
+ const i = new y({ src: t.src });
37
+ await i.load(), e.push(i), o = new b(i);
38
+ } else {
39
+ const i = new I({ src: t.src, cacheFrames: t.cacheFrames, lookahead: t.lookahead });
40
+ await i.load(), e.push(i), o = new v(i);
41
+ }
42
+ return B(o, t), o;
43
+ }
44
+ function j(t) {
45
+ let e = 0;
46
+ for (const o of t.tracks ?? []) for (const i of o.clips) e = Math.max(e, i.end);
47
+ for (const o of t.audio ?? []) e = Math.max(e, o.end);
48
+ return e;
49
+ }
50
+ async function D(t) {
51
+ for (const e of t ?? [])
52
+ e.google ? await d.loadGoogleFont({ family: e.family, weights: e.google.weights, italic: e.google.italic }) : e.src && await d.load({ family: e.family, src: e.src });
53
+ await d.ready();
54
+ }
55
+ async function N(t, e = {}) {
56
+ const o = new k(t.fps), i = new C({
57
+ width: t.width,
58
+ height: t.height,
59
+ timebase: o,
60
+ background: t.background ?? 0,
61
+ origin: t.origin,
62
+ createRenderer: e.createRenderer,
63
+ resolution: e.resolution
64
+ });
65
+ await i.init(), await (e.loadFonts ?? D)(t.fonts);
66
+ const f = [];
67
+ for (const n of t.tracks ?? []) {
68
+ if (n.clips.length === 0) continue;
69
+ const r = new O();
70
+ r.zIndex = n.zIndex ?? 0;
71
+ for (const a of n.clips) r.add(await G(a, f));
72
+ i.addTrack(r);
73
+ }
74
+ for (const n of t.effects ?? []) i.effects.push(c(n));
75
+ const u = new p(o), l = (t.audio?.length ?? 0) > 0;
76
+ for (const n of t.audio ?? []) {
77
+ const r = new z({ src: n.src });
78
+ await r.load(), f.push(r);
79
+ const a = new E();
80
+ a.start = n.start, a.end = n.end, n.sourceIn !== void 0 && (a.sourceIn = n.sourceIn), n.gain !== void 0 && a.gain.setStatic(n.gain), n.fadeIn !== void 0 && (a.fadeIn = n.fadeIn), n.fadeOut !== void 0 && (a.fadeOut = n.fadeOut), u.schedule(a, r);
81
+ }
82
+ const h = t.range ?? [0, j(t)], m = { audio: l, ...t.export };
83
+ return {
84
+ compositor: i,
85
+ audioEngine: u,
86
+ range: h,
87
+ exportOptions: m,
88
+ hasAudio: l,
89
+ dispose() {
90
+ for (const n of f) n.dispose();
91
+ i.dispose();
92
+ }
93
+ };
94
+ }
95
+ function P() {
96
+ return {
97
+ width: 320,
98
+ height: 180,
99
+ fps: 30,
100
+ background: 1052692,
101
+ range: [0, 2],
102
+ tracks: [
103
+ // Background: a full-frame teal rectangle.
104
+ {
105
+ zIndex: 0,
106
+ clips: [
107
+ {
108
+ type: "shape",
109
+ shape: { kind: "rect", width: 320, height: 180, fill: 1013358 },
110
+ start: 0,
111
+ end: 2,
112
+ transform: { anchor: [0, 0], position: [0, 0] }
113
+ }
114
+ ]
115
+ },
116
+ // Foreground: a circle sliding left→right while the title fades in.
117
+ {
118
+ zIndex: 1,
119
+ clips: [
120
+ {
121
+ type: "shape",
122
+ shape: { kind: "ellipse", width: 48, height: 48, fill: 16096779 },
123
+ start: 0,
124
+ end: 2,
125
+ transform: {
126
+ anchor: [0.5, 0.5],
127
+ position: {
128
+ keyframes: [
129
+ { time: 0, value: [40, 180 / 2] },
130
+ { time: 2, value: [280, 180 / 2], easing: "easeInOutCubic" }
131
+ ]
132
+ }
133
+ },
134
+ // A filter (shader) — renders on the server only via Route B's WebGPU backend.
135
+ effects: [{ type: "blur", strength: 4 }]
136
+ },
137
+ {
138
+ type: "text",
139
+ text: "Server-Side Render",
140
+ fontSize: 26,
141
+ fill: 16766474,
142
+ // coloured text — exercises the BGRA swizzle on the Node path
143
+ start: 0,
144
+ end: 2,
145
+ transform: { anchor: [0.5, 0.5], position: [320 / 2, 40] },
146
+ opacity: {
147
+ keyframes: [
148
+ { time: 0, value: 0 },
149
+ { time: 0.6, value: 1, easing: "easeOutQuad" }
150
+ ]
151
+ }
152
+ }
153
+ ]
154
+ }
155
+ ],
156
+ export: { container: "mp4", videoCodec: "avc", bitrate: 2e6 }
157
+ };
158
+ }
159
+ export {
160
+ G as buildClip,
161
+ c as buildEffect,
162
+ N as buildTimeline,
163
+ P as sampleTimeline,
164
+ j as timelineEnd
165
+ };
166
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ import { AutoDetectOptions, MediabunnyModule, Renderer } from '@sequio/engine';
2
+ /** The mediabunny instance carrying the node-av encoders. Call after {@link setupNodeEnvironment}. */
3
+ export declare function getMediabunny(): MediabunnyModule;
4
+ /**
5
+ * Install the browser-global shims and return the GPU handle. Idempotent.
6
+ * Throws if no WebGPU adapter is available (no GPU and no software Vulkan
7
+ * driver) — the caller should surface that as "Route B unavailable on this host".
8
+ */
9
+ export declare function setupNodeEnvironment(): Promise<void>;
10
+ /**
11
+ * A {@link CompositorOptions.createRenderer} factory that builds a PixiJS WebGPU
12
+ * renderer in Node. Requires {@link setupNodeEnvironment} to have run.
13
+ */
14
+ export declare function createNodeWebGPURenderer(options: Partial<AutoDetectOptions>): Promise<Renderer>;
15
+ //# sourceMappingURL=env.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { AudioEngine, Compositor, Renderer } from '@sequio/engine';
2
+ export interface NodeExportOptions {
3
+ fps: number;
4
+ range: [number, number];
5
+ /** Output file path. The extension is corrected to match the encoded container. */
6
+ out: string;
7
+ /** Preferred container; falls back to what node-av can encode. */
8
+ container?: 'mp4' | 'webm';
9
+ videoCodec?: string;
10
+ bitrate?: number;
11
+ /** Mux the {@link AudioEngine}'s offline mix as an audio track. */
12
+ audio?: {
13
+ engine: AudioEngine;
14
+ codec?: string;
15
+ bitrate?: number;
16
+ };
17
+ onProgress?: (p: number) => void;
18
+ }
19
+ /**
20
+ * Render a single frame at `t` and return it as tightly-packed RGBA bytes plus
21
+ * its pixel dimensions. Same await-prepare → render → GPU-readback path as the
22
+ * per-frame loop in {@link renderTimelineToFile}, factored out for the
23
+ * single-frame (image) export that `sequio frame` uses. The `renderer` must be
24
+ * the WebGPU renderer backing `compositor`.
25
+ */
26
+ export declare function renderFrameRGBA(compositor: Compositor, renderer: Renderer, t: number): Promise<{
27
+ rgba: Uint8Array;
28
+ width: number;
29
+ height: number;
30
+ }>;
31
+ /**
32
+ * Render `compositor`'s timeline over `range` at `fps` and write a video file to
33
+ * `out` (extension corrected to the container actually encoded). The `renderer`
34
+ * must be the WebGPU renderer backing the compositor, since we read frames off
35
+ * its GPU. Tries the preferred codec first, falling back through VP9/VP8/WebM if
36
+ * node-av can't encode it.
37
+ */
38
+ export declare function renderTimelineToFile(compositor: Compositor, renderer: Renderer, opts: NodeExportOptions): Promise<{
39
+ frames: number;
40
+ bytes: number;
41
+ out: string;
42
+ container: string;
43
+ videoCodec: string;
44
+ audio: boolean;
45
+ }>;
46
+ //# sourceMappingURL=export-node.d.ts.map
@@ -0,0 +1,24 @@
1
+ import { FontSpec } from '../src/timeline';
2
+ /**
3
+ * Extract the font-file URLs from a Google Fonts css2 stylesheet. Pure and
4
+ * testable — css2 returns one `@font-face { … src: url(X) … }` per subset/weight.
5
+ */
6
+ export declare function parseGoogleFontUrls(css: string): string[];
7
+ /** Register every font a timeline references with `@napi-rs/canvas`'s GlobalFonts. */
8
+ export declare function loadFontsNode(specs: FontSpec[] | undefined): Promise<void>;
9
+ /**
10
+ * Bridge the engine's {@link FontManager} to Node so a composition's own font
11
+ * loads reach the text rasterizer.
12
+ *
13
+ * In a browser, `fonts.load(...)` / `fonts.loadGoogleFont(...)` register into
14
+ * `document.fonts`, which PixiJS's text measurement reads. In Node those calls
15
+ * are no-ops (text is drawn via `@napi-rs/canvas`, whose fonts come from
16
+ * `GlobalFonts`). The `TimelineSpec` route sidesteps this by taking a separate
17
+ * `spec.fonts` list; the **code/bundle** route has no such list — the composition
18
+ * loads fonts imperatively — so we retarget `FontManager`'s (overridable) load
19
+ * hooks at {@link loadFontsNode}. After this runs, the same `fonts.load*` call a
20
+ * composition makes renders identically in the browser preview and the Node
21
+ * render (contract #3). Idempotent; call once after {@link setupNodeEnvironment}.
22
+ */
23
+ export declare function bridgeFontManagerToNode(): void;
24
+ //# sourceMappingURL=fonts-node.d.ts.map
@@ -0,0 +1,28 @@
1
+ import { AssetLoader, Externals, RuntimeBundle } from '@sequio/runtime';
2
+ export interface RenderFrameNodeOptions {
3
+ /** Output image path. The extension is corrected to `.png`. */
4
+ out: string;
5
+ /** Time (seconds) to sample. Clamped to `[0, duration]`. @default 0 */
6
+ time?: number;
7
+ /** Output resolution multiplier (N× the composition size). @default 1 */
8
+ scale?: number;
9
+ /** Extra bare modules the composition may `import` (e.g. `gsap`); see {@link renderBundleToFile}. */
10
+ externals?: Externals;
11
+ /** Resolver for local media assets (`loadAsset('./clip.mp4')`); see {@link renderBundleToFile}. */
12
+ loadAsset?: AssetLoader;
13
+ }
14
+ export interface RenderFrameNodeResult {
15
+ /** The written file path (extension corrected to `.png`). */
16
+ out: string;
17
+ /** The time actually sampled (the request, clamped to `[0, duration]`). */
18
+ time: number;
19
+ width: number;
20
+ height: number;
21
+ bytes: number;
22
+ }
23
+ /**
24
+ * Compile + run `bundle`, seek to `opts.time`, and write that single frame to
25
+ * `opts.out` (as PNG). The built graph is disposed before returning.
26
+ */
27
+ export declare function renderBundleFrameToFile(bundle: RuntimeBundle, opts: RenderFrameNodeOptions): Promise<RenderFrameNodeResult>;
28
+ //# sourceMappingURL=frame-node.d.ts.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@sequio/server/route-b` — the pure-Node (PixiJS WebGPU) server-render route,
3
+ * as a programmatic API. **Node-only**: importing this pulls in the Node
4
+ * environment adapter (`env.ts` → pixi.js, jsdom, @napi-rs/canvas, webgpu,
5
+ * @mediabunny/server), so it is deliberately kept out of the browser-safe main
6
+ * barrel (`@sequio/server`) and reached via this subpath, mirroring how the
7
+ * runtime keeps `@sequio/runtime/node-fs` separate.
8
+ *
9
+ * Needs a WebGPU-capable host: a real GPU or a software Vulkan driver (Mesa
10
+ * lavapipe). See `docs/server-side-rendering.md`.
11
+ */
12
+ export { setupNodeEnvironment, createNodeWebGPURenderer, getMediabunny } from './env';
13
+ export { renderTimelineToFile, renderFrameRGBA, type NodeExportOptions } from './export-node';
14
+ export { loadFontsNode, bridgeFontManagerToNode } from './fonts-node';
15
+ export { renderBundleToFile, type RenderBundleNodeOptions, type RenderBundleNodeResult, } from './render-bundle';
16
+ export { renderBundleFrameToFile, type RenderFrameNodeOptions, type RenderFrameNodeResult, } from './frame-node';
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,41 @@
1
+ import { AssetLoader, Externals, RuntimeBundle } from '@sequio/runtime';
2
+ export interface RenderBundleNodeOptions {
3
+ /** Output file path. The extension is corrected to match the encoded container. */
4
+ out: string;
5
+ /** Output resolution multiplier (N× the composition size). @default 1 */
6
+ scale?: number;
7
+ /** Preferred container; falls back to what node-av can encode. */
8
+ container?: 'mp4' | 'webm';
9
+ videoCodec?: string;
10
+ bitrate?: number;
11
+ onProgress?: (p: number) => void;
12
+ /**
13
+ * Extra bare modules the composition may `import` (merged over the built-in
14
+ * `@sequio/engine` / `@sequio/runtime`). This is the seam a host uses to make a
15
+ * third-party library — e.g. `gsap` — resolvable to user code in the Node
16
+ * render, exactly as it is in the browser preview. The server itself pulls in
17
+ * nothing: the caller (`sequio render`) owns the library and passes it here.
18
+ */
19
+ externals?: Externals;
20
+ /**
21
+ * Resolver for a composition's **local media assets** (`loadAsset('./clip.mp4')`).
22
+ * Passed straight to the {@link Runtime}; `sequio render` supplies a loader that
23
+ * reads the files off disk next to the entry, so a local `image`/`video` renders
24
+ * the same as it previews (contract #3). Assets are never part of the bundle.
25
+ */
26
+ loadAsset?: AssetLoader;
27
+ }
28
+ export interface RenderBundleNodeResult {
29
+ frames: number;
30
+ bytes: number;
31
+ out: string;
32
+ container: string;
33
+ videoCodec: string;
34
+ audio: boolean;
35
+ }
36
+ /**
37
+ * Compile + run `bundle` and render it to `opts.out` via the Node WebGPU pipeline.
38
+ * The caller owns nothing — the built graph is disposed before returning.
39
+ */
40
+ export declare function renderBundleToFile(bundle: RuntimeBundle, opts: RenderBundleNodeOptions): Promise<RenderBundleNodeResult>;
41
+ //# sourceMappingURL=render-bundle.d.ts.map
@@ -0,0 +1,326 @@
1
+ import { createRequire as J } from "node:module";
2
+ import { exportFrameTimes as Q, FontManager as X, buildGoogleCss2Url as Y } from "@sequio/engine";
3
+ import { Runtime as H } from "@sequio/runtime";
4
+ let W = !1;
5
+ const L = Symbol("napi-canvas");
6
+ let I = null;
7
+ function K() {
8
+ if (!I) throw new Error("getMediabunny() before setupNodeEnvironment()");
9
+ return I;
10
+ }
11
+ async function q() {
12
+ if (W) return;
13
+ const { JSDOM: a } = await import("jsdom"), e = new a("<!doctype html><html><body></body></html>");
14
+ for (const o of ["window", "document", "navigator", "HTMLElement", "Node", "DOMParser"])
15
+ if (globalThis[o] === void 0 && e.window[o] !== void 0)
16
+ try {
17
+ globalThis[o] = e.window[o];
18
+ } catch {
19
+ }
20
+ const t = globalThis;
21
+ t.requestAnimationFrame ??= (o) => setTimeout(() => o(Date.now()), 16), t.cancelAnimationFrame ??= (o) => clearTimeout(o), t.addEventListener ??= () => {
22
+ }, t.removeEventListener ??= () => {
23
+ };
24
+ const r = await import("webgpu"), d = r.create([]);
25
+ for (const [o, n] of Object.entries(r.globals ?? {}))
26
+ if (globalThis[o] === void 0)
27
+ try {
28
+ globalThis[o] = n;
29
+ } catch {
30
+ }
31
+ if (Object.defineProperty(globalThis.navigator, "gpu", { value: d, configurable: !0 }), !await d.requestAdapter())
32
+ throw new Error(
33
+ "No WebGPU adapter — Route B needs a GPU or a software Vulkan driver (install Mesa lavapipe: `apt install mesa-vulkan-drivers`)."
34
+ );
35
+ const s = r.globals, f = s?.GPURenderPassEncoder?.prototype;
36
+ if (f?.setBindGroup) {
37
+ const o = f.setBindGroup;
38
+ f.setBindGroup = function(n, ...i) {
39
+ return o.call(this, Number(n), ...i);
40
+ };
41
+ }
42
+ const l = s?.GPUQueue?.prototype;
43
+ if (l?.copyExternalImageToTexture && l.writeTexture) {
44
+ const o = l.copyExternalImageToTexture;
45
+ l.copyExternalImageToTexture = function(...n) {
46
+ const i = n[0], g = n[1], m = i?.source;
47
+ if (m && m[L]) {
48
+ const y = m.width, x = m.height, S = m.getContext("2d").getImageData(0, 0, y, x).data, j = String(g.texture?.format ?? "").startsWith("bgra"), _ = g.premultipliedAlpha === !0, A = new Uint8Array(y * x * 4);
49
+ for (let k = 0; k < y * x; k++) {
50
+ const v = k * 4;
51
+ let R = S[v], U = S[v + 1], E = S[v + 2];
52
+ const G = S[v + 3];
53
+ _ && (R = (R * G + 127) / 255 | 0, U = (U * G + 127) / 255 | 0, E = (E * G + 127) / 255 | 0), j ? (A[v] = E, A[v + 1] = U, A[v + 2] = R, A[v + 3] = G) : (A[v] = R, A[v + 1] = U, A[v + 2] = E, A[v + 3] = G);
54
+ }
55
+ return this.writeTexture(
56
+ { texture: g.texture, mipLevel: g.mipLevel ?? 0, origin: g.origin ?? {}, aspect: g.aspect ?? "all" },
57
+ A,
58
+ { offset: 0, bytesPerRow: y * 4, rowsPerImage: x },
59
+ { width: y, height: x, depthOrArrayLayers: 1 }
60
+ );
61
+ }
62
+ return o.apply(this, n);
63
+ };
64
+ }
65
+ const { createCanvas: h, loadImage: p } = await import("@napi-rs/canvas"), w = (o, n) => {
66
+ const i = h(o || 1, n || 1);
67
+ i[L] = !0;
68
+ const g = i.getContext.bind(i);
69
+ return i.getContext = (m, ...y) => m === "2d" ? g("2d", ...y) : null, i.addEventListener = () => {
70
+ }, i.removeEventListener = () => {
71
+ }, i.style = {}, i.getBoundingClientRect = () => ({
72
+ x: 0,
73
+ y: 0,
74
+ top: 0,
75
+ left: 0,
76
+ right: i.width,
77
+ bottom: i.height,
78
+ width: i.width,
79
+ height: i.height
80
+ }), i;
81
+ }, b = globalThis;
82
+ b.createImageBitmap === void 0 && (b.createImageBitmap = async (o) => {
83
+ const n = o;
84
+ if (!n?.arrayBuffer) return o;
85
+ const { Buffer: i } = await import("node:buffer"), g = await p(i.from(await n.arrayBuffer())), m = w(g.width, g.height);
86
+ return m.getContext("2d").drawImage(g, 0, 0), m;
87
+ });
88
+ const B = await import("pixi.js"), T = globalThis.DOMParser;
89
+ B.DOMAdapter.set({
90
+ createCanvas: (o, n) => w(o, n),
91
+ createImage: () => w(1, 1),
92
+ getCanvasRenderingContext2D: () => h(1, 1).getContext("2d").constructor,
93
+ getWebGLRenderingContext: () => function() {
94
+ },
95
+ getNavigator: () => globalThis.navigator,
96
+ getBaseUrl: () => "http://localhost/",
97
+ getFontFaceSet: () => {
98
+ },
99
+ fetch: (...o) => fetch(...o),
100
+ parseXML: (o) => new T().parseFromString(o, "text/xml")
101
+ });
102
+ const C = B.CanvasSource.test.bind(B.CanvasSource);
103
+ B.CanvasSource.test = ((o) => !!(o && o[L]) || C(o));
104
+ const u = J(import.meta.url), { registerMediabunnyServer: F } = u("@mediabunny/server");
105
+ F(), I = u("mediabunny");
106
+ const { setMediabunnyModule: O, setFrameImageExtractor: P } = await import("@sequio/engine");
107
+ O(I), P(async (o) => {
108
+ const n = o, i = n.codedWidth, g = n.codedHeight, m = new Uint8Array(n.allocationSize({ format: "RGBA" }));
109
+ await n.copyTo(m, { format: "RGBA" });
110
+ const y = w(i, g), x = y.getContext("2d"), D = x.createImageData(i, g);
111
+ return D.data.set(m), x.putImageData(D, 0, 0), y;
112
+ });
113
+ const M = await import("node-web-audio-api");
114
+ for (const o of ["OfflineAudioContext", "AudioContext", "AudioBuffer"])
115
+ globalThis[o] === void 0 && M[o] && (globalThis[o] = M[o]);
116
+ W = !0;
117
+ }
118
+ async function V(a) {
119
+ const { WebGPURenderer: e } = await import("pixi.js"), t = new e();
120
+ return await t.init({
121
+ width: a.width,
122
+ height: a.height,
123
+ background: a.background,
124
+ backgroundAlpha: 1,
125
+ // MSAA for shape/text edges. Export renders into antialiased RenderTextures
126
+ // (RT-level MSAA is what actually smooths the pixels read back), but honor the
127
+ // compositor's choice here too so the renderer and its targets agree.
128
+ antialias: a.antialias ?? !0,
129
+ // Honor the compositor's resolution. Text and other Canvas-rasterized content
130
+ // (PIXI.Text glyph atlases) are generated at the *renderer's* resolution, so
131
+ // dropping it here rasterizes glyphs at 1× and upscales them into a higher-res
132
+ // frame — blurry text at `--scale 2`. Match the render-texture resolution.
133
+ resolution: a.resolution ?? 1
134
+ // No canvas presentation — we render to RenderTextures and read them back.
135
+ }), t;
136
+ }
137
+ const Z = (a) => a === "vp8" || a === "vp9" || a === "av1";
138
+ function ee(a) {
139
+ const e = [];
140
+ a.codec ? e.push({ container: a.container ?? (Z(a.codec) ? "webm" : "mp4"), codec: a.codec }) : a.container === "webm" && e.push({ container: "webm", codec: "vp9" }), e.push(
141
+ { container: "mp4", codec: "avc" },
142
+ { container: "webm", codec: "vp9" },
143
+ { container: "webm", codec: "vp8" },
144
+ { container: "mp4", codec: "av1" }
145
+ );
146
+ const t = /* @__PURE__ */ new Set();
147
+ return e.filter((r) => t.has(r.codec) ? !1 : (t.add(r.codec), !0));
148
+ }
149
+ async function z(a, e) {
150
+ const t = a.gpu.device, r = a.texture.getGpuSource(e), d = e.pixelWidth, c = e.pixelHeight, s = globalThis, f = Math.ceil(d * 4 / 256) * 256, l = t.createBuffer({ size: f * c, usage: s.GPUBufferUsage.COPY_DST | s.GPUBufferUsage.MAP_READ }), h = t.createCommandEncoder();
151
+ h.copyTextureToBuffer(
152
+ { texture: r },
153
+ { buffer: l, bytesPerRow: f, rowsPerImage: c },
154
+ { width: d, height: c, depthOrArrayLayers: 1 }
155
+ ), t.queue.submit([h.finish()]), await l.mapAsync(s.GPUMapMode.READ);
156
+ const p = new Uint8Array(l.getMappedRange()), w = new Uint8Array(d * c * 4);
157
+ for (let b = 0; b < c; b++) {
158
+ const B = b * f;
159
+ for (let T = 0; T < d; T++) {
160
+ const C = B + T * 4, u = (b * d + T) * 4;
161
+ w[u] = p[C + 2], w[u + 1] = p[C + 1], w[u + 2] = p[C], w[u + 3] = p[C + 3];
162
+ }
163
+ }
164
+ return l.unmap(), l.destroy(), w;
165
+ }
166
+ async function te(a, e, t) {
167
+ const r = e;
168
+ await a.prepare(t);
169
+ const d = a.renderToTexture(t);
170
+ try {
171
+ const c = d.source;
172
+ return { rgba: await z(r, c), width: c.pixelWidth, height: c.pixelHeight };
173
+ } finally {
174
+ d.destroy(!0);
175
+ }
176
+ }
177
+ function oe(a) {
178
+ return /VideoFrame is not defined|not supported by this browser|VideoEncoder is not|Invalid (video|audio) codec|Unknown codec/.test(String(a));
179
+ }
180
+ async function ae(a, e, t) {
181
+ const { Output: r, Mp4OutputFormat: d, WebMOutputFormat: c, FilePathTarget: s, VideoSampleSource: f, VideoSample: l, AudioBufferSource: h } = K(), p = await import("node:fs"), w = await import("node:path"), b = Q(t.range, t.fps), B = e, T = ee({ container: t.container, codec: t.videoCodec });
182
+ let C = null;
183
+ for (const u of T) {
184
+ const F = w.format({ ...w.parse(t.out), base: void 0, ext: `.${u.container}` }), O = u.container === "webm" ? new c() : new d({ fastStart: "in-memory" }), P = new r({ format: O, target: new s(F) });
185
+ let M = null;
186
+ try {
187
+ const o = new f({ codec: u.codec, bitrate: t.bitrate ?? 5e6 });
188
+ if (P.addVideoTrack(o), t.audio) {
189
+ const n = u.container === "webm" ? "opus" : t.audio.codec ?? "aac";
190
+ M = new h({ codec: n, bitrate: t.audio.bitrate ?? 128e3 }), P.addAudioTrack(M);
191
+ }
192
+ await P.start();
193
+ for (let n = 0; n < b.length; n++) {
194
+ const i = b[n];
195
+ await a.prepare(i);
196
+ const g = a.renderToTexture(i);
197
+ try {
198
+ const m = g.source, y = await z(B, m), x = new l(y, { format: "RGBA", codedWidth: m.pixelWidth, codedHeight: m.pixelHeight, timestamp: i, duration: 1 / t.fps });
199
+ await o.add(x), x.close();
200
+ } finally {
201
+ g.destroy(!0);
202
+ }
203
+ t.onProgress?.((n + 1) / b.length);
204
+ }
205
+ if (M && t.audio) {
206
+ const n = await t.audio.engine.renderOffline(Math.max(0, t.range[1] - t.range[0]));
207
+ await M.add(n);
208
+ }
209
+ return await P.finalize(), { frames: b.length, bytes: p.statSync(F).size, out: F, container: u.container, videoCodec: u.codec, audio: !!M };
210
+ } catch (o) {
211
+ C = o, await P.cancel().catch(() => {
212
+ });
213
+ try {
214
+ p.unlinkSync(F);
215
+ } catch {
216
+ }
217
+ if (oe(o)) {
218
+ console.warn(`⚠ node-av can't encode ${u.container}/${u.codec} here — trying the next codec…`);
219
+ continue;
220
+ }
221
+ throw o;
222
+ }
223
+ }
224
+ throw new Error(
225
+ "No server-side video encoder available (tried " + T.map((u) => u.codec).join(", ") + "). Route B encodes through @mediabunny/server (node-av / FFmpeg); ensure its native binary is built (`pnpm rebuild node-av`). Last error: " + String(C)
226
+ );
227
+ }
228
+ function re(a) {
229
+ const e = [], t = /src:\s*url\(([^)]+)\)/g;
230
+ let r;
231
+ for (; r = t.exec(a); )
232
+ e.push(r[1].replace(/["']/g, "").trim());
233
+ return e;
234
+ }
235
+ const ne = "Mozilla/5.0";
236
+ async function N(a) {
237
+ if (!a?.length) return;
238
+ const { GlobalFonts: e } = await import("@napi-rs/canvas"), { Buffer: t } = await import("node:buffer");
239
+ for (const r of a)
240
+ if (r.google) {
241
+ const d = Y(r.google.cssBase ?? "https://fonts.googleapis.com/css2", {
242
+ family: r.family,
243
+ weights: r.google.weights,
244
+ italic: r.google.italic
245
+ }), c = await (await fetch(d, { headers: { "User-Agent": ne } })).text();
246
+ for (const s of re(c)) {
247
+ const f = t.from(await (await fetch(s)).arrayBuffer());
248
+ e.register(f, r.family);
249
+ }
250
+ } else if (r.src) {
251
+ const d = t.from(await (await fetch(r.src)).arrayBuffer());
252
+ e.register(d, r.family);
253
+ }
254
+ }
255
+ function $() {
256
+ const a = X.prototype;
257
+ a.loadFace = (e) => {
258
+ const t = /^\s*url\(\s*['"]?([^'")]+)['"]?\s*\)\s*$/.exec(e.src)?.[1] ?? (/^\s*(url|local)\s*\(/.test(e.src) ? null : e.src);
259
+ return t ? N([{ family: e.family, src: t }]) : Promise.resolve();
260
+ }, a.loadGoogle = (e) => N([{ family: e.family, google: { weights: e.weights, italic: e.italic, cssBase: e.cssBase } }]);
261
+ }
262
+ async function ge(a, e) {
263
+ await q(), $();
264
+ const t = Math.max(1, e.scale ?? 1);
265
+ let r = null;
266
+ const c = await (await new H({ ...a, externals: e.externals, loadAsset: e.loadAsset }).run()).build({
267
+ target: "server",
268
+ compositorOptions: {
269
+ createRenderer: async (s) => (r = await V(s), r),
270
+ resolution: t
271
+ }
272
+ });
273
+ try {
274
+ const s = c.compositor.timebase.fps;
275
+ return await ae(c.compositor, r, {
276
+ fps: s,
277
+ range: [0, c.duration],
278
+ out: e.out,
279
+ container: e.container,
280
+ videoCodec: e.videoCodec,
281
+ bitrate: e.bitrate,
282
+ onProgress: e.onProgress
283
+ });
284
+ } finally {
285
+ c.dispose();
286
+ }
287
+ }
288
+ async function ie(a, e, t, r) {
289
+ const { createCanvas: d } = await import("@napi-rs/canvas"), c = await import("node:fs"), s = d(t, r), f = s.getContext("2d"), l = f.createImageData(t, r);
290
+ l.data.set(e), f.putImageData(l, 0, 0);
291
+ const h = await s.encode("png");
292
+ return c.writeFileSync(a, h), h.length;
293
+ }
294
+ function ce(a) {
295
+ return a.replace(/\.[^./\\]*$/, "") + ".png";
296
+ }
297
+ async function me(a, e) {
298
+ await q(), $();
299
+ const t = Math.max(1, e.scale ?? 1);
300
+ let r = null;
301
+ const c = await (await new H({ ...a, externals: e.externals, loadAsset: e.loadAsset }).run()).build({
302
+ target: "server",
303
+ compositorOptions: {
304
+ createRenderer: async (s) => (r = await V(s), r),
305
+ resolution: t
306
+ }
307
+ });
308
+ try {
309
+ const s = Math.min(Math.max(e.time ?? 0, 0), c.duration), { rgba: f, width: l, height: h } = await te(c.compositor, r, s), p = ce(e.out), w = await ie(p, f, l, h);
310
+ return { out: p, time: s, width: l, height: h, bytes: w };
311
+ } finally {
312
+ c.dispose();
313
+ }
314
+ }
315
+ export {
316
+ $ as bridgeFontManagerToNode,
317
+ V as createNodeWebGPURenderer,
318
+ K as getMediabunny,
319
+ N as loadFontsNode,
320
+ me as renderBundleFrameToFile,
321
+ ge as renderBundleToFile,
322
+ te as renderFrameRGBA,
323
+ ae as renderTimelineToFile,
324
+ q as setupNodeEnvironment
325
+ };
326
+ //# sourceMappingURL=route-b.js.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `@sequio/server` — server-side rendering.
3
+ *
4
+ * Public surface: the serializable {@link TimelineSpec} protocol and the
5
+ * {@link buildTimeline} builder that rebuilds the SDK object graph from it. Both
6
+ * SSR routes (Route A headless Chrome in `route-a/`, Route B pure Node in
7
+ * `route-b/`) and the editor's "Server Render" button consume this protocol.
8
+ *
9
+ * The Node-only render workers (`route-b/*`) are intentionally NOT re-exported
10
+ * here so importing this barrel stays browser-safe (no jsdom / napi-canvas).
11
+ */
12
+ export * from './timeline';
13
+ export { sampleTimeline } from './sample-timeline';
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,3 @@
1
+ import { TimelineSpec } from './timeline';
2
+ export declare function sampleTimeline(): TimelineSpec;
3
+ //# sourceMappingURL=sample-timeline.d.ts.map
@@ -0,0 +1,172 @@
1
+ import { AudioEngine, AutoDetectOptions, BLEND_MODES, Compositor, Effect, Renderer, ShapeSpec, VisualClip } from '@sequio/engine';
2
+ /** Named easing curves usable in a keyframe spec. */
3
+ export type EasingName = 'linear' | 'hold' | 'easeInQuad' | 'easeOutQuad' | 'easeInOutQuad' | 'easeInCubic' | 'easeOutCubic' | 'easeInOutCubic';
4
+ /**
5
+ * A filter/effect on a clip or the whole frame. Effects are shaders — they need
6
+ * a GPU renderer (so on the server they require Route B's WebGPU backend, not the
7
+ * Canvas fallback). Params are static here for brevity.
8
+ */
9
+ export type EffectSpec = {
10
+ type: 'blur';
11
+ strength?: number;
12
+ } | {
13
+ type: 'color';
14
+ brightness?: number;
15
+ contrast?: number;
16
+ saturation?: number;
17
+ };
18
+ /** A property that is either a constant value or a keyframed animation. */
19
+ export type PropSpec<T> = T | {
20
+ keyframes: Array<{
21
+ time: number;
22
+ value: T;
23
+ easing?: EasingName;
24
+ }>;
25
+ };
26
+ export interface TransformSpec {
27
+ position?: PropSpec<[number, number]>;
28
+ scale?: PropSpec<[number, number]>;
29
+ rotation?: PropSpec<number>;
30
+ /** Normalized anchor (0..1); static only. Default `[0.5, 0.5]`. */
31
+ anchor?: [number, number];
32
+ }
33
+ interface BaseClipSpec {
34
+ /** Timeline interval, seconds. `end` is exclusive (`[start, end)`). */
35
+ start: number;
36
+ end: number;
37
+ sourceIn?: number;
38
+ sourceOut?: number;
39
+ speed?: number;
40
+ opacity?: PropSpec<number>;
41
+ blendMode?: BLEND_MODES;
42
+ transform?: TransformSpec;
43
+ /** Filters applied to this clip (needs Route B's GPU backend on the server). */
44
+ effects?: EffectSpec[];
45
+ }
46
+ export interface TextClipSpec extends BaseClipSpec {
47
+ type: 'text';
48
+ text: string;
49
+ fontFamily?: string;
50
+ fontSize?: PropSpec<number>;
51
+ fill?: number | string;
52
+ }
53
+ export interface ShapeClipSpec extends BaseClipSpec {
54
+ type: 'shape';
55
+ shape: ShapeSpec;
56
+ }
57
+ export interface ImageClipSpec extends BaseClipSpec {
58
+ type: 'image';
59
+ /** URL/data-URI the headless browser can fetch. */
60
+ src: string;
61
+ }
62
+ export interface VideoClipSpec extends BaseClipSpec {
63
+ type: 'video';
64
+ /** URL/data-URI the headless browser can fetch. */
65
+ src: string;
66
+ /** Decoded-frame ring size (bounds memory for big sources). */
67
+ cacheFrames?: number;
68
+ lookahead?: number;
69
+ }
70
+ export type ClipSpec = TextClipSpec | ShapeClipSpec | ImageClipSpec | VideoClipSpec;
71
+ export interface TrackSpec {
72
+ zIndex?: number;
73
+ clips: ClipSpec[];
74
+ }
75
+ /** An audio clip scheduled into the {@link AudioEngine} for the exported mix. */
76
+ export interface AudioClipSpec {
77
+ src: string;
78
+ start: number;
79
+ end: number;
80
+ sourceIn?: number;
81
+ gain?: number;
82
+ fadeIn?: number;
83
+ fadeOut?: number;
84
+ }
85
+ export interface FontSpec {
86
+ family: string;
87
+ /** Self-hosted URL. Mutually exclusive with {@link google}. */
88
+ src?: string;
89
+ /** Load from Google Fonts instead of a self-hosted URL. */
90
+ google?: {
91
+ weights?: number[];
92
+ italic?: boolean;
93
+ cssBase?: string;
94
+ };
95
+ }
96
+ export interface ExportSpec {
97
+ container?: 'mp4' | 'webm';
98
+ videoCodec?: string;
99
+ audioCodec?: string;
100
+ bitrate?: number;
101
+ audioBitrate?: number;
102
+ /** Mux the audio mix. Auto-enabled when the timeline has audio clips. */
103
+ audio?: boolean;
104
+ }
105
+ /** The full, JSON-serializable description of a timeline to render. */
106
+ export interface TimelineSpec {
107
+ width: number;
108
+ height: number;
109
+ fps: number;
110
+ background?: number;
111
+ /** Normalized coordinate origin (`0..1`); default `[0, 0]`. */
112
+ origin?: [number, number];
113
+ /**
114
+ * Explicit render range `[start, end]` in seconds. Defaults to
115
+ * `[0, maxClipEnd]` — the whole timeline.
116
+ */
117
+ range?: [number, number];
118
+ fonts?: FontSpec[];
119
+ tracks?: TrackSpec[];
120
+ audio?: AudioClipSpec[];
121
+ /** Global filters over the whole composite (a master grade / blur). */
122
+ effects?: EffectSpec[];
123
+ export?: ExportSpec;
124
+ }
125
+ /** Everything the exporter path needs, plus a teardown that frees every source. */
126
+ export interface BuiltTimeline {
127
+ compositor: Compositor;
128
+ audioEngine: AudioEngine;
129
+ range: [number, number];
130
+ exportOptions: ExportSpec;
131
+ hasAudio: boolean;
132
+ /** Dispose the compositor and every source/decoder this build created. */
133
+ dispose(): void;
134
+ }
135
+ /** Construct a built-in effect (filter) from its spec. */
136
+ export declare function buildEffect(spec: EffectSpec): Effect;
137
+ /**
138
+ * Construct one clip from its spec. Image/Video sources are loaded (async) and
139
+ * pushed onto `sources` for later disposal. Text/Shape clips need no source and
140
+ * resolve without touching the browser, so this is unit-testable for them.
141
+ */
142
+ export declare function buildClip(spec: ClipSpec, sources: {
143
+ dispose(): void;
144
+ }[]): Promise<VisualClip>;
145
+ /** Largest clip end across all visual + audio clips (the timeline duration). */
146
+ export declare function timelineEnd(spec: TimelineSpec): number;
147
+ /**
148
+ * Overrides that let {@link buildTimeline} run outside a browser (server-side
149
+ * rendering). Both default to the browser behaviour.
150
+ */
151
+ export interface BuildOverrides {
152
+ /** Custom GPU renderer factory (e.g. Node WebGPU via Dawn). */
153
+ createRenderer?: (options: Partial<AutoDetectOptions>) => Promise<Renderer>;
154
+ /** Custom font loader (e.g. Node `GlobalFonts.register`) replacing `document.fonts`. */
155
+ loadFonts?: (specs: FontSpec[] | undefined) => Promise<void>;
156
+ /**
157
+ * Backing-store scale for the output. `2` renders/exports at 2× the timeline's
158
+ * `width`×`height` (crisper text/vectors, larger file). In a browser this
159
+ * defaults to `devicePixelRatio`; in Node there's none, so the default is `1`
160
+ * and a caller (e.g. the SSR worker's `--scale`) can bump it here.
161
+ */
162
+ resolution?: number;
163
+ }
164
+ /**
165
+ * Rebuild the SDK object graph from a {@link TimelineSpec} and initialize the
166
+ * GPU renderer, ready for {@link Exporter.export}. Loads fonts and every
167
+ * image/video source up front. The caller must {@link BuiltTimeline.dispose}
168
+ * when done (frees the compositor and every source/decoder).
169
+ */
170
+ export declare function buildTimeline(spec: TimelineSpec, overrides?: BuildOverrides): Promise<BuiltTimeline>;
171
+ export {};
172
+ //# sourceMappingURL=timeline.d.ts.map
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@sequio/server",
3
+ "version": "0.1.0",
4
+ "description": "Server-side rendering for sequio: a serializable TimelineSpec protocol plus Route B — pure Node, PixiJS WebGPU. (Route A, headless Chrome, is a repo-internal harness and not part of the published package.)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "slightc",
8
+ "homepage": "https://sequio-demo.vercel.app/",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/slightc/sequio.git",
12
+ "directory": "packages/server"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/slightc/sequio/issues"
16
+ },
17
+ "main": "./dist/index.js",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/src/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/src/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ },
25
+ "./route-b": {
26
+ "types": "./dist/route-b/index.d.ts",
27
+ "import": "./dist/route-b.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "!dist/**/*.map"
33
+ ],
34
+ "sideEffects": false,
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "keywords": [
42
+ "server-side-rendering",
43
+ "pixijs",
44
+ "webgpu",
45
+ "video",
46
+ "headless"
47
+ ],
48
+ "dependencies": {
49
+ "mediabunny": "^1.49.0",
50
+ "pixi.js": "^8.6.0",
51
+ "@sequio/engine": "^0.1.1",
52
+ "@sequio/runtime": "^0.1.0"
53
+ },
54
+ "optionalDependencies": {
55
+ "@mediabunny/server": "^1.50.4",
56
+ "@napi-rs/canvas": "^1.0.2",
57
+ "jsdom": "^29.1.1",
58
+ "node-web-audio-api": "^2.0.0",
59
+ "webgpu": "^0.4.0"
60
+ },
61
+ "scripts": {
62
+ "dev": "vite",
63
+ "build": "vite build",
64
+ "typecheck": "tsc --noEmit",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest",
67
+ "verify:ssr": "node scripts/verify-page.cjs route-a/ssr-render.html __SSR_TEST__ \"server-side render\"",
68
+ "verify:ssr-node": "tsx route-b/render.ts --verify --out .ssr-out/node-demo.mp4",
69
+ "verify:ssr-node-audio": "tsx route-b/verify-audio.ts",
70
+ "verify:ssr-node-font": "tsx route-b/verify-font.ts",
71
+ "verify:ssr-node-media": "tsx route-b/verify-media.ts",
72
+ "ssr:render": "node route-a/ssr-render.cjs",
73
+ "ssr:render-node": "tsx route-b/render.ts"
74
+ }
75
+ }