@effect-motion/export 0.2.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/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @effect-motion/export
2
+
3
+ Node-only export tools for [effect-motion](https://www.npmjs.com/package/effect-motion): rasterize SVG frames to PNG (via [resvg](https://github.com/RazrFalcon/resvg)) and encode them into a video file (via ffmpeg).
4
+
5
+ This package is for **server-side rendering** (Node). Browser playback lives in `@effect-motion/react` and does not depend on this package.
6
+
7
+ ## Usage
8
+
9
+ ```ts
10
+ import { NodeServices } from "@effect/platform-node";
11
+ import { Effect } from "effect";
12
+ import { Video } from "@effect-motion/export";
13
+
14
+ // scene → MP4, one call
15
+ await Effect.runPromise(
16
+ Video.render(scene, "out.mp4", {
17
+ settings: { width: 1920, height: 1080, frameRate: 60 },
18
+ }).pipe(Effect.provide(NodeServices.layer)),
19
+ );
20
+ ```
21
+
22
+ `Ffmpeg.encode` and `Resvg.rasterize` are the lower-level stages if you want to drive the pipeline yourself.
23
+
24
+ ## Bundled ffmpeg
25
+
26
+ Video encoding uses the [`ffmpeg-static`](https://www.npmjs.com/package/ffmpeg-static) binary by default — a full build that includes **libx264**, so H.264 output works out of the box with no system ffmpeg required. Installing this package downloads that binary (~45 MB) for your platform.
27
+
28
+ To use a system or custom ffmpeg instead, pass `binary`:
29
+
30
+ ```ts
31
+ Video.render(scene, "out.mp4", { binary: "ffmpeg" }); // system ffmpeg on PATH
32
+ Video.render(scene, "out.mp4", { binary: "/path/to/ffmpeg" }); // a specific build
33
+ ```
34
+
35
+ > **License note.** The `ffmpeg-static` build (and libx264) is **GPL-3.0**. It is a standalone executable this package invokes over a process boundary — it is not linked into effect-motion's own code, which stays MIT. If you redistribute the bundled binary, the ffmpeg/libx264 GPL terms apply to that binary; pass your own `binary` to avoid shipping it.
@@ -0,0 +1,45 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Stream from "effect/Stream";
3
+ import { ChildProcessSpawner } from "effect/unstable/process";
4
+ type Spawner = ChildProcessSpawner.ChildProcessSpawner;
5
+ /** Options for {@link encode}. */
6
+ export interface EncodeOptions {
7
+ /** Input framerate handed to ffmpeg (`-framerate`). */
8
+ readonly frameRate: number;
9
+ /**
10
+ * ffmpeg binary; defaults to the bundled `ffmpeg-static` build (falling
11
+ * back to `"ffmpeg"` on PATH). Set to `"ffmpeg"` or a path to use a
12
+ * system/custom ffmpeg instead.
13
+ */
14
+ readonly binary?: string | undefined;
15
+ /**
16
+ * Extra ffmpeg arguments appended before the output path — e.g.
17
+ * `["-crf", "18"]` or a `-vf` scale filter. Escape hatch for the codec
18
+ * surface this wrapper does not model.
19
+ */
20
+ readonly extraArgs?: ReadonlyArray<string> | undefined;
21
+ }
22
+ declare const EncodeError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
23
+ readonly _tag: "EncodeError";
24
+ } & Readonly<A>;
25
+ /**
26
+ * A failure encoding a video: ffmpeg not spawnable (not installed / not on
27
+ * PATH), or a nonzero exit. `stderr` carries ffmpeg's diagnostics when the
28
+ * process ran; it is empty when the binary could not be spawned at all.
29
+ */
30
+ export declare class EncodeError extends EncodeError_base<{
31
+ readonly message: string;
32
+ readonly stderr: string;
33
+ readonly cause: unknown;
34
+ }> {
35
+ }
36
+ /**
37
+ * Encode a stream of PNG frames into a video file at `outPath`.
38
+ *
39
+ * Pipes the PNG bytes into ffmpeg's stdin via the `image2pipe` demuxer;
40
+ * when the stream ends, stdin closes and ffmpeg finalizes the file. A
41
+ * nonzero exit or an unspawnable binary fails with a tagged
42
+ * {@link EncodeError}.
43
+ */
44
+ export declare const encode: (pngStream: Stream.Stream<Uint8Array>, outPath: string, options: EncodeOptions) => Effect.Effect<void, EncodeError, Spawner>;
45
+ export {};
package/dist/Ffmpeg.js ADDED
@@ -0,0 +1,90 @@
1
+ import * as Data from "effect/Data";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Fiber from "effect/Fiber";
4
+ import * as Stream from "effect/Stream";
5
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
6
+ import ffmpegStatic from "ffmpeg-static";
7
+ /**
8
+ * ffmpeg encoding is an export tool, not a renderer: it consumes the PNG
9
+ * frames the rasterizer produces and pipes them into ffmpeg over stdin
10
+ * (`-f image2pipe`), yielding a video file. No frame touches disk.
11
+ *
12
+ * By default the bundled `ffmpeg-static` binary is used (a full build with
13
+ * libx264, so H.264 works out of the box); `binary` overrides it to point
14
+ * at a system or custom ffmpeg. If the bundle is unavailable for the host
15
+ * platform, it falls back to `"ffmpeg"` on PATH. Process spawning goes
16
+ * through the `ChildProcessSpawner` service — the consumer provides it
17
+ * (e.g. `NodeServices.layer` from `@effect/platform-node`), the same way
18
+ * `Resvg.rasterizeToFile` takes a consumer-provided `FileSystem`.
19
+ */
20
+ // the bundled ffmpeg (null only on a platform ffmpeg-static doesn't ship)
21
+ const bundledFfmpeg = ffmpegStatic;
22
+ /**
23
+ * A failure encoding a video: ffmpeg not spawnable (not installed / not on
24
+ * PATH), or a nonzero exit. `stderr` carries ffmpeg's diagnostics when the
25
+ * process ran; it is empty when the binary could not be spawned at all.
26
+ */
27
+ export class EncodeError extends Data.TaggedError("EncodeError") {
28
+ }
29
+ // broadly-playable defaults: H.264 8-bit, faststart MP4 for progressive
30
+ // playback. Flags chosen to be stable across every maintained ffmpeg (4.x+).
31
+ const outputArgs = [
32
+ "-c:v",
33
+ "libx264",
34
+ "-pix_fmt",
35
+ "yuv420p",
36
+ "-movflags",
37
+ "+faststart",
38
+ ];
39
+ const decoder = new TextDecoder();
40
+ /**
41
+ * Encode a stream of PNG frames into a video file at `outPath`.
42
+ *
43
+ * Pipes the PNG bytes into ffmpeg's stdin via the `image2pipe` demuxer;
44
+ * when the stream ends, stdin closes and ffmpeg finalizes the file. A
45
+ * nonzero exit or an unspawnable binary fails with a tagged
46
+ * {@link EncodeError}.
47
+ */
48
+ export const encode = (pngStream, outPath, options) => Effect.gen(function* () {
49
+ const binary = options.binary ?? bundledFfmpeg ?? "ffmpeg";
50
+ const args = [
51
+ "-f",
52
+ "image2pipe",
53
+ "-framerate",
54
+ String(options.frameRate),
55
+ "-i",
56
+ "-",
57
+ ...outputArgs,
58
+ ...(options.extraArgs ?? []),
59
+ "-y",
60
+ outPath,
61
+ ];
62
+ const command = ChildProcess.make(binary, args, {
63
+ // PlatformError from the stdin pipe would otherwise leak into the
64
+ // stream's error channel; the stream elements are pure PNG bytes,
65
+ // so this cast only re-labels the (unreachable) error type
66
+ stdin: pngStream,
67
+ stdout: "ignore",
68
+ stderr: "pipe",
69
+ });
70
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
71
+ yield* Effect.scoped(Effect.gen(function* () {
72
+ const handle = yield* spawner.spawn(command);
73
+ // collect stderr concurrently: an unconsumed stderr pipe can
74
+ // fill and deadlock ffmpeg, and it is the error diagnostic
75
+ const stderrFiber = yield* Effect.forkChild(Stream.runFold(handle.stderr, () => "", (acc, chunk) => acc + decoder.decode(chunk)));
76
+ const code = yield* handle.exitCode;
77
+ const stderr = yield* Fiber.join(stderrFiber);
78
+ if (code !== 0) {
79
+ return yield* new EncodeError({
80
+ message: `ffmpeg exited with code ${code}`,
81
+ stderr,
82
+ cause: code,
83
+ });
84
+ }
85
+ })).pipe(Effect.catchTag("PlatformError", (cause) => Effect.fail(new EncodeError({
86
+ message: `Could not run "${binary}". Install ffmpeg and ensure it is on your PATH (or pass options.binary).`,
87
+ stderr: "",
88
+ cause,
89
+ }))));
90
+ });
@@ -0,0 +1,12 @@
1
+ import type { ResvgRenderOptions } from "@resvg/resvg-js";
2
+ import type * as Context from "effect/Context";
3
+ /**
4
+ * Map a scene's declared fonts (the `Fonts` annotation) to resvg font
5
+ * options: every entry with a `src.path` becomes a `fontFiles` entry.
6
+ * Url-only entries are a browser concern and are skipped. System fonts
7
+ * stay loaded (resvg's default) — declared fonts ADD faces; pass
8
+ * `loadSystemFonts: false` yourself for fully self-contained output.
9
+ */
10
+ export declare const resvgOptions: (scene: {
11
+ readonly annotations: Context.Context<never>;
12
+ }) => ResvgRenderOptions;
package/dist/Fonts.js ADDED
@@ -0,0 +1,12 @@
1
+ import { Fonts } from "effect-motion";
2
+ /**
3
+ * Map a scene's declared fonts (the `Fonts` annotation) to resvg font
4
+ * options: every entry with a `src.path` becomes a `fontFiles` entry.
5
+ * Url-only entries are a browser concern and are skipped. System fonts
6
+ * stay loaded (resvg's default) — declared fonts ADD faces; pass
7
+ * `loadSystemFonts: false` yourself for fully self-contained output.
8
+ */
9
+ export const resvgOptions = (scene) => {
10
+ const fontFiles = Fonts.get(scene).flatMap((font) => font.src.path === undefined ? [] : [font.src.path]);
11
+ return fontFiles.length === 0 ? {} : { font: { fontFiles } };
12
+ };
@@ -0,0 +1,27 @@
1
+ import { type ResvgRenderOptions } from "@resvg/resvg-js";
2
+ import * as Effect from "effect/Effect";
3
+ import * as FileSystem from "effect/FileSystem";
4
+ import type * as PlatformError from "effect/PlatformError";
5
+ /**
6
+ * Thin Effect wrappers over resvg. Rasterization is an export tool, not a
7
+ * renderer: it consumes the SVG document strings the string sink produces
8
+ * (or any other SVG) and yields PNG bytes.
9
+ */
10
+ /** resvg's own options, untranslated — font config and fitTo live here */
11
+ export type { ResvgRenderOptions } from "@resvg/resvg-js";
12
+ declare const RasterizeError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
13
+ readonly _tag: "RasterizeError";
14
+ } & Readonly<A>;
15
+ /** A failure raised by resvg while parsing or rendering an SVG document. */
16
+ export declare class RasterizeError extends RasterizeError_base<{
17
+ readonly cause: unknown;
18
+ }> {
19
+ }
20
+ /**
21
+ * Rasterize an SVG document string to PNG bytes. Output dimensions come
22
+ * from the document itself; the string sink stamps the frame's
23
+ * width/height on the root.
24
+ */
25
+ export declare const rasterize: (svg: string, options?: ResvgRenderOptions) => Effect.Effect<Uint8Array, RasterizeError>;
26
+ /** `rasterize`, then persist through the FileSystem service. */
27
+ export declare const rasterizeToFile: (svg: string, path: string, options?: ResvgRenderOptions) => Effect.Effect<void, RasterizeError | PlatformError.PlatformError, FileSystem.FileSystem>;
package/dist/Resvg.js ADDED
@@ -0,0 +1,22 @@
1
+ import { Resvg as ResvgJs } from "@resvg/resvg-js";
2
+ import * as Data from "effect/Data";
3
+ import * as Effect from "effect/Effect";
4
+ import * as FileSystem from "effect/FileSystem";
5
+ /** A failure raised by resvg while parsing or rendering an SVG document. */
6
+ export class RasterizeError extends Data.TaggedError("RasterizeError") {
7
+ }
8
+ /**
9
+ * Rasterize an SVG document string to PNG bytes. Output dimensions come
10
+ * from the document itself; the string sink stamps the frame's
11
+ * width/height on the root.
12
+ */
13
+ export const rasterize = (svg, options) => Effect.try({
14
+ try: () => new Uint8Array(new ResvgJs(svg, options ?? null).render().asPng()),
15
+ catch: (cause) => new RasterizeError({ cause }),
16
+ });
17
+ /** `rasterize`, then persist through the FileSystem service. */
18
+ export const rasterizeToFile = (svg, path, options) => Effect.gen(function* () {
19
+ const bytes = yield* rasterize(svg, options);
20
+ const fs = yield* FileSystem.FileSystem;
21
+ yield* fs.writeFile(path, bytes);
22
+ });
@@ -0,0 +1,54 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type * as Scope from "effect/Scope";
3
+ import type { ChildProcessSpawner } from "effect/unstable/process";
4
+ import { type Entity, Scene } from "effect-motion";
5
+ import * as Ffmpeg from "./Ffmpeg";
6
+ import * as Resvg from "./Resvg";
7
+ type SceneRunner = typeof Scene.tick extends Effect.Effect<unknown, unknown, infer RR> ? RR : never;
8
+ type SceneInternalR = SceneRunner | Scope.Scope;
9
+ /**
10
+ * The one-call export path: a scene becomes a video file. Composes the whole
11
+ * pipeline — `Scene.stream` → SVG string sink → resvg → ffmpeg — reading the
12
+ * framerate and dimensions from the scene's own frame metadata and feeding
13
+ * declared fonts to the rasterizer.
14
+ */
15
+ /**
16
+ * Scene settings honored by the export path — a subset of the runner's
17
+ * settings, passed through to `Scene.stream`. Framerate and dimensions set
18
+ * here become the video's framerate and dimensions.
19
+ */
20
+ export interface VideoSceneSettings {
21
+ readonly frameRate?: number;
22
+ readonly width?: number;
23
+ readonly height?: number;
24
+ /** Set to `Infinity` for an intentionally infinite scene (with `frames`). */
25
+ readonly maxFrames?: number;
26
+ }
27
+ /** Options for {@link render}. */
28
+ export interface VideoOptions {
29
+ /**
30
+ * Cap the number of frames encoded. WITHOUT it, a scene that never ends
31
+ * produces an encode that never ends — an infinite scene must set this.
32
+ */
33
+ readonly frames?: number;
34
+ /**
35
+ * ffmpeg binary; defaults to the bundled `ffmpeg-static` build (falls
36
+ * back to `"ffmpeg"` on PATH). Pass `"ffmpeg"` or a path for a
37
+ * system/custom ffmpeg.
38
+ */
39
+ readonly binary?: string;
40
+ /** Extra ffmpeg arguments, appended before the output path. */
41
+ readonly extraArgs?: ReadonlyArray<string>;
42
+ /** How many frames to rasterize concurrently (order preserved). Default 4. */
43
+ readonly concurrency?: number;
44
+ /** Scene framerate/dimensions/maxFrames for this export. */
45
+ readonly settings?: VideoSceneSettings;
46
+ }
47
+ /**
48
+ * Render a scene to a video file at `outPath`.
49
+ *
50
+ * Fails with {@link Ffmpeg.EncodeError} on odd output dimensions (invalid for
51
+ * `yuv420p`) — before any ffmpeg process is spawned — or on an ffmpeg failure.
52
+ */
53
+ export declare const render: <E, R, Entities extends Entity.AnyEntity>(scene: Scene.Scene<E, R, Entities>, outPath: string, options?: VideoOptions) => Effect.Effect<void, E | Ffmpeg.EncodeError | Resvg.RasterizeError, Exclude<R, Scope.Scope | SceneInternalR> | ChildProcessSpawner.ChildProcessSpawner>;
54
+ export {};
package/dist/Video.js ADDED
@@ -0,0 +1,53 @@
1
+ import { Layer } from "effect";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Sink from "effect/Sink";
4
+ import * as Stream from "effect/Stream";
5
+ import { Scene, Svg } from "effect-motion";
6
+ import * as Ffmpeg from "./Ffmpeg";
7
+ import { resvgOptions } from "./Fonts";
8
+ import * as Resvg from "./Resvg";
9
+ // SVG string sink + built-in shape renderers, provided internally so callers
10
+ // don't wire renderers for the export path
11
+ const renderLayer = Svg.layer.pipe(Layer.provideMerge(Svg.shapesLayer));
12
+ /**
13
+ * Render a scene to a video file at `outPath`.
14
+ *
15
+ * Fails with {@link Ffmpeg.EncodeError} on odd output dimensions (invalid for
16
+ * `yuv420p`) — before any ffmpeg process is spawned — or on an ffmpeg failure.
17
+ */
18
+ export const render = (scene, outPath, options = {}) => Effect.scoped(Effect.gen(function* () {
19
+ const renderer = yield* Svg.SvgRenderer.Context;
20
+ const fontOpts = resvgOptions(scene);
21
+ const concurrency = options.concurrency ?? 4;
22
+ let frames = Scene.stream(scene, options.settings ?? {});
23
+ if (options.frames !== undefined) {
24
+ frames = Stream.take(frames, options.frames);
25
+ }
26
+ // peel the first frame for metadata (framerate → ffmpeg -framerate,
27
+ // dimensions → yuv420p even-size check) before spawning ffmpeg
28
+ const [first, rest] = yield* Stream.peel(frames, Sink.head());
29
+ if (first._tag === "None") {
30
+ return; // empty scene: nothing to encode
31
+ }
32
+ const meta = first.value;
33
+ if (meta.width % 2 !== 0 || meta.height % 2 !== 0) {
34
+ const odd = meta.width % 2 !== 0
35
+ ? `width ${meta.width}`
36
+ : `height ${meta.height}`;
37
+ return yield* new Ffmpeg.EncodeError({
38
+ message: `Video dimensions must be even for yuv420p, got ${odd}. ` +
39
+ `Use even scene dimensions, or pass extraArgs with a scale filter.`,
40
+ stderr: "",
41
+ cause: meta,
42
+ });
43
+ }
44
+ const allFrames = Stream.concat(Stream.make(meta), rest);
45
+ const pngStream = Stream.mapEffect(allFrames, (frame) => renderer
46
+ .render(frame, {})
47
+ .pipe(Effect.flatMap((svg) => Resvg.rasterize(svg, fontOpts))), { concurrency });
48
+ yield* Ffmpeg.encode(pngStream, outPath, {
49
+ frameRate: meta.frameRate,
50
+ binary: options.binary,
51
+ extraArgs: options.extraArgs,
52
+ });
53
+ })).pipe(Effect.provide(renderLayer));
@@ -0,0 +1,7 @@
1
+ export * as Ffmpeg from "./Ffmpeg";
2
+ export { EncodeError, type EncodeOptions } from "./Ffmpeg";
3
+ export * as Fonts from "./Fonts";
4
+ export * as Resvg from "./Resvg";
5
+ export { RasterizeError, type ResvgRenderOptions } from "./Resvg";
6
+ export type { VideoOptions } from "./Video";
7
+ export * as Video from "./Video";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * as Ffmpeg from "./Ffmpeg";
2
+ export { EncodeError } from "./Ffmpeg";
3
+ export * as Fonts from "./Fonts";
4
+ export * as Resvg from "./Resvg";
5
+ export { RasterizeError } from "./Resvg";
6
+ export * as Video from "./Video";
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@effect-motion/export",
3
+ "version": "0.2.0",
4
+ "description": "Node-only export tools for effect-motion: rasterize SVG frames to PNG via resvg",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/julia-script/effect-motion.git",
10
+ "directory": "packages/export"
11
+ },
12
+ "homepage": "https://github.com/julia-script/effect-motion#readme",
13
+ "bugs": "https://github.com/julia-script/effect-motion/issues",
14
+ "keywords": [
15
+ "effect",
16
+ "motion",
17
+ "export",
18
+ "svg",
19
+ "png",
20
+ "ffmpeg",
21
+ "video"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@resvg/resvg-js": "^2.6.2",
37
+ "ffmpeg-static": "^5.3.0",
38
+ "effect-motion": "^0.2.0"
39
+ },
40
+ "peerDependencies": {
41
+ "effect": ">=4.0.0-beta.94"
42
+ },
43
+ "devDependencies": {
44
+ "@effect/platform-node": "4.0.0-beta.94",
45
+ "@types/node": "^26.1.1",
46
+ "effect": "4.0.0-beta.94",
47
+ "typescript": "^7.0.2",
48
+ "vitest": "^4.1.10"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.build.json",
52
+ "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
53
+ "test": "vitest run",
54
+ "check": "tsc --noEmit"
55
+ }
56
+ }