@effect-motion/cli 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.
@@ -0,0 +1,197 @@
1
+ import { Player, type PlayerProps } from "@effect-motion/react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { projectRoot } from "./project";
4
+
5
+ // minimal local mirror of the CLI's config types — the app deliberately
6
+ // avoids importing @effect-motion/cli so it has zero resolution assumptions
7
+ // beyond the project's own dependencies
8
+ type TargetLike = {
9
+ readonly name?: string;
10
+ readonly scene?: string;
11
+ readonly settings?: Record<string, unknown>;
12
+ };
13
+ type ConfigLike = { readonly targets?: ReadonlyArray<TargetLike> };
14
+
15
+ type SceneEntry = {
16
+ /** picker identity: `target:<name>` for config targets, `file:<path>` otherwise */
17
+ readonly key: string;
18
+ readonly label: string;
19
+ /** project-relative module path */
20
+ readonly path: string;
21
+ readonly registered: boolean;
22
+ readonly settings: PlayerProps["settings"] | undefined;
23
+ readonly load: () => Promise<unknown>;
24
+ };
25
+
26
+ // every scene in src/scenes is previewable without registration; vite keeps
27
+ // this list current as files appear and disappear
28
+ const globbed = import.meta.glob("../../src/scenes/*.ts");
29
+
30
+ const normalize = (p: string) => p.replace(/^\.\//, "").replace(/^\//, "");
31
+ const globKey = (p: string) => normalize(p.replace(/^(\.\.\/)+/, ""));
32
+ const fileLabel = (key: string) =>
33
+ key
34
+ .split("/")
35
+ .at(-1)
36
+ ?.replace(/\.(ts|tsx|mts|js|mjs)$/, "") ?? key;
37
+
38
+ const loadByAbsolutePath = (key: string) => () =>
39
+ import(/* @vite-ignore */ `/@fs${projectRoot}/${key}`);
40
+
41
+ const buildEntries = (config: ConfigLike | null): ReadonlyArray<SceneEntry> => {
42
+ // one entry per config target (a scene file can back several targets with
43
+ // different settings), plus one per scenes-dir file no target references
44
+ const globLoaders = new Map(
45
+ Object.entries(globbed).map(([path, load]) => [globKey(path), load]),
46
+ );
47
+ const referenced = new Set<string>();
48
+ const entries: Array<SceneEntry> = [];
49
+ for (const target of config?.targets ?? []) {
50
+ if (typeof target?.scene !== "string" || typeof target.name !== "string")
51
+ continue;
52
+ const path = normalize(target.scene);
53
+ referenced.add(path);
54
+ entries.push({
55
+ key: `target:${target.name}`,
56
+ label: target.name,
57
+ path,
58
+ registered: true,
59
+ // a registered scene previews with its target settings so the
60
+ // preview aspect matches the export
61
+ settings: target.settings as PlayerProps["settings"],
62
+ load: globLoaders.get(path) ?? loadByAbsolutePath(path),
63
+ });
64
+ }
65
+ for (const [path, load] of globLoaders) {
66
+ if (referenced.has(path)) continue;
67
+ entries.push({
68
+ key: `file:${path}`,
69
+ label: fileLabel(path),
70
+ path,
71
+ registered: false,
72
+ settings: undefined,
73
+ load,
74
+ });
75
+ }
76
+ return entries.sort((a, b) => a.label.localeCompare(b.label));
77
+ };
78
+
79
+ // selection survives the full-page reloads that scene edits trigger
80
+ const selectionFromHash = () =>
81
+ decodeURIComponent(window.location.hash.slice(1)) || null;
82
+
83
+ type SceneState =
84
+ | { readonly _tag: "idle" }
85
+ | { readonly _tag: "loading" }
86
+ | { readonly _tag: "error"; readonly key: string; readonly message: string }
87
+ | {
88
+ readonly _tag: "ready";
89
+ readonly key: string;
90
+ readonly scene: PlayerProps["scene"];
91
+ };
92
+
93
+ export const App = () => {
94
+ const [config, setConfig] = useState<ConfigLike | null>(null);
95
+ const [configError, setConfigError] = useState<string | null>(null);
96
+ const [selected, setSelected] = useState<string | null>(selectionFromHash);
97
+ const [state, setState] = useState<SceneState>({ _tag: "idle" });
98
+
99
+ useEffect(() => {
100
+ // the config is optional for studio — a project without one still
101
+ // previews everything in src/scenes
102
+ import(/* @vite-ignore */ `/@fs${projectRoot}/motion.config.ts`).then(
103
+ (module_) => setConfig((module_.default ?? null) as ConfigLike | null),
104
+ () => setConfig(null),
105
+ );
106
+ }, []);
107
+
108
+ const entries = useMemo(() => buildEntries(config), [config]);
109
+ const entry = entries.find((e) => e.key === selected) ?? entries[0];
110
+
111
+ useEffect(() => {
112
+ if (entry === undefined) return;
113
+ let cancelled = false;
114
+ setState({ _tag: "loading" });
115
+ setConfigError(null);
116
+ entry.load().then(
117
+ (module_) => {
118
+ if (cancelled) return;
119
+ const scene = (module_ as { scene?: PlayerProps["scene"] }).scene;
120
+ if (scene === undefined) {
121
+ setState({
122
+ _tag: "error",
123
+ key: entry.key,
124
+ message: `${entry.path} has no \`scene\` export`,
125
+ });
126
+ } else {
127
+ setState({ _tag: "ready", key: entry.key, scene });
128
+ }
129
+ },
130
+ (error) => {
131
+ if (cancelled) return;
132
+ setState({
133
+ _tag: "error",
134
+ key: entry.key,
135
+ message: `failed to load ${entry.path}\n\n${error instanceof Error ? error.message : String(error)}`,
136
+ });
137
+ },
138
+ );
139
+ return () => {
140
+ cancelled = true;
141
+ };
142
+ }, [entry]);
143
+
144
+ return (
145
+ <>
146
+ <nav className="studio-sidebar">
147
+ <h1>Scenes</h1>
148
+ {entries.map((e) => (
149
+ <button
150
+ key={e.key}
151
+ type="button"
152
+ className="studio-scene-button"
153
+ data-active={e.key === entry?.key}
154
+ onClick={() => {
155
+ window.location.hash = encodeURIComponent(e.key);
156
+ setSelected(e.key);
157
+ }}
158
+ >
159
+ {e.label}
160
+ <small>{e.registered ? e.path : `${e.path} (unregistered)`}</small>
161
+ </button>
162
+ ))}
163
+ {entries.length === 0 && (
164
+ <p className="studio-empty">
165
+ No scenes found — add one in src/scenes/
166
+ </p>
167
+ )}
168
+ {configError !== null && <p className="studio-empty">{configError}</p>}
169
+ </nav>
170
+ <main className="studio-main">
171
+ {state._tag === "error" && (
172
+ <div className="studio-error">
173
+ <h2>Scene failed to load</h2>
174
+ <pre>{state.message}</pre>
175
+ </div>
176
+ )}
177
+ {state._tag === "ready" && (
178
+ <Player
179
+ key={state.key}
180
+ scene={state.scene}
181
+ autoPlay
182
+ defaultRepeatMode
183
+ {...(entry?.settings !== undefined
184
+ ? { settings: entry.settings }
185
+ : {})}
186
+ />
187
+ )}
188
+ {(state._tag === "idle" || state._tag === "loading") &&
189
+ entries.length === 0 && (
190
+ <p className="studio-empty">
191
+ Create src/scenes/my-scene.ts exporting a `scene` to get started.
192
+ </p>
193
+ )}
194
+ </main>
195
+ </>
196
+ );
197
+ };
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,96 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>motion studio</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ }
11
+ body {
12
+ margin: 0;
13
+ background: #16161d;
14
+ color: #e8e8ef;
15
+ font-family:
16
+ ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
17
+ }
18
+ #root {
19
+ display: flex;
20
+ min-height: 100vh;
21
+ }
22
+ .studio-sidebar {
23
+ width: 220px;
24
+ flex-shrink: 0;
25
+ border-right: 1px solid #2a2a35;
26
+ padding: 12px;
27
+ box-sizing: border-box;
28
+ }
29
+ .studio-sidebar h1 {
30
+ font-size: 13px;
31
+ text-transform: uppercase;
32
+ letter-spacing: 0.08em;
33
+ color: #8a8a99;
34
+ margin: 4px 4px 12px;
35
+ }
36
+ .studio-scene-button {
37
+ display: block;
38
+ width: 100%;
39
+ text-align: left;
40
+ background: none;
41
+ border: none;
42
+ border-radius: 6px;
43
+ color: inherit;
44
+ font: inherit;
45
+ padding: 7px 10px;
46
+ cursor: pointer;
47
+ }
48
+ .studio-scene-button:hover {
49
+ background: #23232e;
50
+ }
51
+ .studio-scene-button[data-active="true"] {
52
+ background: #2e2e3d;
53
+ }
54
+ .studio-scene-button small {
55
+ display: block;
56
+ color: #8a8a99;
57
+ font-size: 11px;
58
+ }
59
+ .studio-main {
60
+ flex: 1;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ padding: 24px;
65
+ box-sizing: border-box;
66
+ min-width: 0;
67
+ }
68
+ .studio-error {
69
+ max-width: 640px;
70
+ background: #2b1d22;
71
+ border: 1px solid #6e2f3f;
72
+ border-radius: 8px;
73
+ padding: 16px 20px;
74
+ }
75
+ .studio-error h2 {
76
+ margin: 0 0 8px;
77
+ font-size: 15px;
78
+ color: #ff7d9c;
79
+ }
80
+ .studio-error pre {
81
+ margin: 0;
82
+ white-space: pre-wrap;
83
+ word-break: break-word;
84
+ font-size: 12px;
85
+ color: #d8b4bf;
86
+ }
87
+ .studio-empty {
88
+ color: #8a8a99;
89
+ }
90
+ </style>
91
+ </head>
92
+ <body>
93
+ <div id="root"></div>
94
+ <script type="module" src="./main.tsx"></script>
95
+ </body>
96
+ </html>
@@ -0,0 +1,11 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+
5
+ const container = document.getElementById("root");
6
+ if (container === null) throw new Error("studio: #root not found");
7
+ createRoot(container).render(
8
+ <StrictMode>
9
+ <App />
10
+ </StrictMode>,
11
+ );
@@ -0,0 +1,3 @@
1
+ // placeholder for typechecking — `motion studio` overwrites this file with
2
+ // the absolute project root when it copies the app into .motion/studio
3
+ export const projectRoot = "";
@@ -0,0 +1,59 @@
1
+ # Working in this project
2
+
3
+ This is an [effect-motion](https://github.com/julia-script/effect-motion) project: motion graphics written as deterministic, frame-exact scenes in TypeScript, rendered to video. Read this before writing or editing scenes.
4
+
5
+ ## Layout and commands
6
+
7
+ - `src/scenes/*.ts` — one scene per module, each exporting `scene`. Any file here is previewable without registration.
8
+ - `src/main.ts` — the movie: an ordinary scene that sequences the others (`Scene.play` + `handle.finished`). Nothing is special about it.
9
+ - `motion.config.ts` — render targets. Output is always `<output>/<name>.mp4`; never write output paths by hand.
10
+ - `src/assets/` — static files (images, fonts).
11
+ - `motion studio` — browser preview with hot reload (scene picker lists config targets plus unregistered scenes).
12
+ - `motion render [name...]` — render targets; `motion render ./src/scenes/foo.ts` renders one file with defaults. Flags beat config beat library defaults. `--verbose` prints full error cause chains.
13
+
14
+ Verify a scene change by rendering it (`motion render <target>`) or checking it in the running studio — not by reading code alone.
15
+
16
+ ## Writing scenes
17
+
18
+ A scene is an Effect generator: instantiate entities, then yield animations.
19
+
20
+ ```ts
21
+ import { Color, Motion, Physics, Scene, Shapes } from "effect-motion";
22
+
23
+ export const scene = Scene.make(function* () {
24
+ const dot = yield* Scene.instantiate(Shapes.Circle, {
25
+ x: 300, y: 540, radius: 80, fill: Color.hex("#7f5af0"),
26
+ });
27
+ yield* Motion.tweenTo(dot, { x: 1620 }, "1200 millis", "easeInOutCubic");
28
+ yield* Physics.springTo(dot, { y: 300 }, Physics.springs.wobbly);
29
+ });
30
+ ```
31
+
32
+ - **Animators come in pairs**: `verb(instance, from, to, …)` (explicit origin) and `verbTo(instance, to, …)` (origin read from the instance). Prefer the `To` form unless you need a fixed origin.
33
+ - **Prefer semantic helpers** (`Motion.moveTo`, `Motion.fadeTo`, `Physics.springTo`) over raw `tweenTo` when one exists — they carry per-entity meaning (moving a Line translates both endpoints; moving a Group carries its subtree). Use `tweenTo` for fields without a trait (`radius`, `width`, custom fields).
34
+ - **Springs have no duration** — length emerges from the simulation (presets in `Physics.springs`). Springy motion on raw fields uses elastic/bounce *easings*, not physics.
35
+ - **Every animator is a dual**: `Motion.tweenTo(dot, …)` or `dot.pipe(Motion.tweenTo(…))` — both are idiomatic.
36
+ - **Composition**: sequence by yielding one animation after another; `Scene.all([...])` runs them together; `Scene.chain`/`Scene.stagger` sequence with schedules; `Scene.fork` starts a branch you can join later; `Scene.play(otherScene)` mounts a whole scene (await `handle.finished`). `Scene.finish` marks a scene's semantic end — anything after it is a tail that keeps playing without being waited on.
37
+
38
+ ## Determinism rules (non-negotiable)
39
+
40
+ - **Never** use `Math.random()`, `Date.now()`, or any wall-clock/OS state in a scene — every run must be byte-identical. Use the provided seeded random (`Effect.random`, seeded from `settings.seed`).
41
+ - Durations land exactly on target on the final frame; springs snap on settle. Don't add "fudge" frames.
42
+ - Scene coordinates are the `settings.width`/`height` of the target that renders them (this template: 1920×1080). `dpr` scales output pixels, not coordinates.
43
+ - The `effect` dependency is pinned **exactly** — upgrading it can change seeded-random sequences. Never bump it casually; upgrade `effect` and `effect-motion` together, deliberately.
44
+
45
+ ## Config
46
+
47
+ ```ts
48
+ export default defineConfig({
49
+ targets: [{
50
+ name: "intro", // unique — doubles as the output basename
51
+ scene: "./src/scenes/intro.ts",
52
+ settings: { width: 1920, height: 1080, frameRate: 60, dpr: 1 },
53
+ output: "./output", // a DIRECTORY; file name is derived
54
+ // frames: 600 // REQUIRED if the scene is infinite
55
+ }],
56
+ });
57
+ ```
58
+
59
+ A scene used by several targets renders once per target (e.g. different resolutions). An infinite scene (one that never finishes) must set `frames`, or rendering would never end.
@@ -0,0 +1,3 @@
1
+ node_modules/
2
+ output/
3
+ .motion/
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from "@effect-motion/cli";
2
+
3
+ // Each target is one rendered video: <output>/<name>.mp4
4
+ // `motion render` renders all of them; `motion render <name>` picks one.
5
+ export default defineConfig({
6
+ targets: [
7
+ {
8
+ name: "hello-world",
9
+ scene: "./src/scenes/hello-world.ts",
10
+ settings: { width: 1920, height: 1080, frameRate: 60 },
11
+ output: "./output",
12
+ },
13
+ {
14
+ name: "main",
15
+ scene: "./src/main.ts",
16
+ settings: { width: 1920, height: 1080, frameRate: 60 },
17
+ output: "./output",
18
+ },
19
+ ],
20
+ });
File without changes
@@ -0,0 +1,12 @@
1
+ import { Scene } from "effect-motion";
2
+ import { scene as helloWorld } from "./scenes/hello-world";
3
+
4
+ // The movie: an ordinary scene that sequences the scenes in src/scenes.
5
+ // Nothing is special about this file — it is one more target in
6
+ // motion.config.ts. Add scenes and chain them here.
7
+ export const scene = Scene.make(function* () {
8
+ const hello = yield* Scene.play(helloWorld);
9
+ yield* hello.finished;
10
+ // const next = yield* Scene.play(anotherScene);
11
+ // yield* next.finished;
12
+ });
@@ -0,0 +1,15 @@
1
+ import { Color, Motion, Scene, Shapes } from "effect-motion";
2
+
3
+ // A scene is a generator: instantiate entities, then yield animations.
4
+ // Preview it with `motion studio`, render it with `motion render`.
5
+ export const scene = Scene.make(function* () {
6
+ const circle = yield* Scene.instantiate(Shapes.Circle, {
7
+ x: 300,
8
+ y: 540,
9
+ radius: 80,
10
+ fill: Color.hex("#7f5af0"),
11
+ });
12
+
13
+ yield* Motion.tweenTo(circle, { x: 1620 }, "1200 millis", "easeInOutCubic");
14
+ yield* Motion.fadeTo(circle, 0, "400 millis");
15
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "exactOptionalPropertyTypes": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "lib": ["ES2022"],
12
+ "types": ["node"]
13
+ },
14
+ "include": ["src", "motion.config.ts"]
15
+ }