@effect-motion/cli 0.2.0 → 0.4.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 +3 -21
- package/dist/Config.d.ts +28 -9
- package/dist/MotionCliError.d.ts +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -4
- package/dist/commands/render.d.ts +0 -2
- package/dist/commands/render.js +0 -4
- package/dist/commands/studio.js +5 -0
- package/package.json +5 -6
- package/studio-app/App.tsx +38 -11
- package/dist/commands/init.d.ts +0 -9
- package/dist/commands/init.js +0 -94
- package/dist/pins.d.ts +0 -23
- package/dist/pins.js +0 -23
- package/dist/scaffold.d.ts +0 -20
- package/dist/scaffold.js +0 -86
- package/templates/default/AGENTS.md +0 -59
- package/templates/default/_gitignore +0 -3
- package/templates/default/motion.config.ts +0 -20
- package/templates/default/src/assets/.gitkeep +0 -0
- package/templates/default/src/main.ts +0 -12
- package/templates/default/src/scenes/hello-world.ts +0 -15
- package/templates/default/tsconfig.json +0 -15
package/README.md
CHANGED
|
@@ -1,32 +1,14 @@
|
|
|
1
1
|
# @effect-motion/cli
|
|
2
2
|
|
|
3
|
-
Command line for [effect-motion](https://github.com/julia-script/effect-motion):
|
|
3
|
+
Command line for [effect-motion](https://github.com/julia-script/effect-motion): preview scenes in the browser and render videos — all driven by one `motion.config.ts`.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
|
|
6
|
+
pnpm create effect-motion # scaffold a project (npm/yarn/bun create work too)
|
|
7
7
|
motion studio # preview scenes with hot reload
|
|
8
8
|
motion render # render every target to MP4
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
`motion init [directory]` scaffolds a single-project workspace:
|
|
14
|
-
|
|
15
|
-
```
|
|
16
|
-
my-project/
|
|
17
|
-
├─ src/
|
|
18
|
-
│ ├─ scenes/hello-world.ts # a scene: a module exporting `scene`
|
|
19
|
-
│ ├─ assets/ # static files
|
|
20
|
-
│ └─ main.ts # the movie — an ordinary scene composing the others
|
|
21
|
-
├─ motion.config.ts # render targets
|
|
22
|
-
├─ AGENTS.md # authoring rules for AI coding agents
|
|
23
|
-
├─ package.json # EXACT pins of effect-motion + effect
|
|
24
|
-
└─ tsconfig.json
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Answering `.` scaffolds into the current directory and names the project after it. The package-manager prompt defaults to whichever manager invoked the CLI; `--pm <pnpm|npm|yarn|bun>` and `--no-install` skip prompts/install.
|
|
28
|
-
|
|
29
|
-
Dependency versions are pinned **exactly** — the `effect` pin is a determinism invariant (upgrading effect can change seeded random sequences), so upgrade both together, deliberately.
|
|
11
|
+
Scaffolding lives in the [`create-effect-motion`](https://www.npmjs.com/package/create-effect-motion) package — this CLI is installed as a devDependency of the projects it creates.
|
|
30
12
|
|
|
31
13
|
## motion.config.ts
|
|
32
14
|
|
package/dist/Config.d.ts
CHANGED
|
@@ -6,20 +6,39 @@ import type * as Runner from "effect-motion/Runner";
|
|
|
6
6
|
* ConfigLoader.ts.
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
|
-
* Per-target
|
|
10
|
-
* honor, plus `dpr
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Per-target playback settings: the Runner `Settings` subset an export can
|
|
10
|
+
* honor, plus `dpr` (mapped to the export package's supersampling option).
|
|
11
|
+
* Resolution and background are NOT here — they are the scene's own
|
|
12
|
+
* composition config, set in `Scene.make(gen, { width, height, backgroundColor })`.
|
|
13
13
|
*/
|
|
14
14
|
export interface TargetSettings {
|
|
15
|
-
readonly width?: number;
|
|
16
|
-
readonly height?: number;
|
|
17
15
|
readonly frameRate?: number;
|
|
18
16
|
readonly seed?: Runner.Seed;
|
|
19
17
|
readonly maxFrames?: number;
|
|
20
|
-
readonly backgroundColor?: Runner.Settings["backgroundColor"];
|
|
21
18
|
readonly dpr?: number;
|
|
22
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Studio preview options for a target's Player — how the scene PLAYS in
|
|
22
|
+
* `motion studio`, as opposed to `settings`, which is what the scene IS.
|
|
23
|
+
* `motion render` ignores them. Field meanings match `PlayerProps` in
|
|
24
|
+
* `@effect-motion/react`.
|
|
25
|
+
*/
|
|
26
|
+
export interface PlayerOptions {
|
|
27
|
+
readonly autoPlay?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Preview-only frame rate (the Player's `fps` prop) — wins over
|
|
30
|
+
* `settings.frameRate` in the studio (e.g. preview a heavy 60fps target
|
|
31
|
+
* at 30). The scene RUNS at this rate, so previewed frames are not the
|
|
32
|
+
* export's frames.
|
|
33
|
+
*/
|
|
34
|
+
readonly fps?: number;
|
|
35
|
+
/** Initial repeat mode; the studio's repeat button toggles it after. */
|
|
36
|
+
readonly defaultRepeatMode?: boolean;
|
|
37
|
+
/** Declare a never-ending scene so the player windows its buffer. */
|
|
38
|
+
readonly isInfinite?: boolean;
|
|
39
|
+
readonly prebufferedFrames?: number;
|
|
40
|
+
readonly bufferCapacity?: number;
|
|
41
|
+
}
|
|
23
42
|
/** v1 ships MP4 only; the field exists so more containers slot in later. */
|
|
24
43
|
export type OutputFormat = "mp4";
|
|
25
44
|
export interface MotionTarget {
|
|
@@ -28,6 +47,8 @@ export interface MotionTarget {
|
|
|
28
47
|
/** Path to a module exporting `scene`, relative to the config file. */
|
|
29
48
|
readonly scene: string;
|
|
30
49
|
readonly settings?: TargetSettings;
|
|
50
|
+
/** Studio-only: how this target previews (autoplay, repeat, …). */
|
|
51
|
+
readonly player?: PlayerOptions;
|
|
31
52
|
/** Output DIRECTORY (never a file), relative to the config file. */
|
|
32
53
|
readonly output?: string;
|
|
33
54
|
readonly format?: OutputFormat;
|
|
@@ -49,8 +70,6 @@ export declare const DEFAULT_FORMAT: OutputFormat;
|
|
|
49
70
|
export declare const validateConfig: (value: unknown, configPath: string) => MotionConfig;
|
|
50
71
|
/** Flag values a `motion render` invocation can lay over a target. */
|
|
51
72
|
export interface RenderOverrides {
|
|
52
|
-
readonly width?: number;
|
|
53
|
-
readonly height?: number;
|
|
54
73
|
readonly frameRate?: number;
|
|
55
74
|
readonly dpr?: number;
|
|
56
75
|
readonly seed?: Runner.Seed;
|
package/dist/MotionCliError.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* signature; adding a failure mode is a union-member addition handled
|
|
5
5
|
* exhaustively at exactly one place (the top-level reporter in bin.ts).
|
|
6
6
|
*/
|
|
7
|
-
export type MotionCliReason = "ConfigNotFound" | "ConfigInvalid" | "SceneLoadFailed" | "UnknownTarget" | "
|
|
7
|
+
export type MotionCliReason = "ConfigNotFound" | "ConfigInvalid" | "SceneLoadFailed" | "UnknownTarget" | "RenderFailed" | "StudioFailed";
|
|
8
8
|
declare const MotionCliError_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 & {
|
|
9
9
|
readonly _tag: "MotionCliError";
|
|
10
10
|
} & Readonly<A>;
|
package/dist/cli.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as Effect from "effect/Effect";
|
|
|
2
2
|
import { CliError, Command } from "effect/unstable/cli";
|
|
3
3
|
import { type MotionCliError } from "./MotionCliError.js";
|
|
4
4
|
export declare const rootCommand: Command.Command<"motion", {}, {}, MotionCliError, never>;
|
|
5
|
-
export declare const CLI_VERSION:
|
|
5
|
+
export declare const CLI_VERSION: string;
|
|
6
6
|
/**
|
|
7
7
|
* The single exhaustive failure boundary (design D3a): MotionCliError
|
|
8
8
|
* prints its message (cause chain under --verbose) and sets a non-zero
|
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import * as Console from "effect/Console";
|
|
2
3
|
import * as Effect from "effect/Effect";
|
|
3
4
|
import { CliError, Command, Flag, GlobalFlag } from "effect/unstable/cli";
|
|
4
|
-
import { initCommand } from "./commands/init.js";
|
|
5
5
|
import { renderCommand } from "./commands/render.js";
|
|
6
6
|
import { studioCommand } from "./commands/studio.js";
|
|
7
7
|
import { renderForTerminal } from "./MotionCliError.js";
|
|
8
|
-
import { PINS } from "./pins.js";
|
|
9
8
|
// registered globally so `--verbose` parses anywhere on the command line;
|
|
10
9
|
// the reporter reads argv directly because it sits outside handler context
|
|
11
10
|
const verboseFlag = GlobalFlag.setting("verbose")({
|
|
12
11
|
flag: Flag.boolean("verbose").pipe(Flag.withDescription("Print full error cause chains")),
|
|
13
12
|
});
|
|
14
|
-
export const rootCommand = Command.make("motion").pipe(Command.withDescription("effect-motion: scaffold projects
|
|
15
|
-
export const CLI_VERSION =
|
|
13
|
+
export const rootCommand = Command.make("motion").pipe(Command.withDescription("effect-motion: preview scenes and render videos (scaffold new projects with `pnpm create effect-motion`)"), Command.withSubcommands([studioCommand, renderCommand]), Command.withGlobalFlags([verboseFlag]));
|
|
14
|
+
export const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
16
15
|
/**
|
|
17
16
|
* The single exhaustive failure boundary (design D3a): MotionCliError
|
|
18
17
|
* prints its message (cause chain under --verbose) and sets a non-zero
|
|
@@ -5,8 +5,6 @@ import { Command } from "effect/unstable/cli";
|
|
|
5
5
|
import { MotionCliError } from "../MotionCliError.js";
|
|
6
6
|
export declare const renderCommand: Command.Command<"render", {
|
|
7
7
|
readonly config: Option.Option<string>;
|
|
8
|
-
readonly width: Option.Option<number>;
|
|
9
|
-
readonly height: Option.Option<number>;
|
|
10
8
|
readonly fps: Option.Option<number>;
|
|
11
9
|
readonly dpr: Option.Option<number>;
|
|
12
10
|
readonly seed: Option.Option<string>;
|
package/dist/commands/render.js
CHANGED
|
@@ -13,8 +13,6 @@ import { makeViteLoader } from "../ViteLoader.js";
|
|
|
13
13
|
const opt = (o) => Option.getOrUndefined(o);
|
|
14
14
|
const renderFlags = {
|
|
15
15
|
config: Flag.optional(Flag.string("config").pipe(Flag.withDescription("Path to a motion.config.ts (tsc -p style)"))),
|
|
16
|
-
width: Flag.optional(Flag.integer("width")),
|
|
17
|
-
height: Flag.optional(Flag.integer("height")),
|
|
18
16
|
fps: Flag.optional(Flag.integer("fps").pipe(Flag.withDescription("Frame rate override"))),
|
|
19
17
|
dpr: Flag.optional(Flag.float("dpr").pipe(Flag.withDescription("Supersampling factor (output pixels = scene × dpr)"))),
|
|
20
18
|
seed: Flag.optional(Flag.string("seed")),
|
|
@@ -26,8 +24,6 @@ const renderFlags = {
|
|
|
26
24
|
};
|
|
27
25
|
const overridesFrom = (input) => {
|
|
28
26
|
const raw = {
|
|
29
|
-
width: opt(input.width),
|
|
30
|
-
height: opt(input.height),
|
|
31
27
|
frameRate: opt(input.fps),
|
|
32
28
|
dpr: opt(input.dpr),
|
|
33
29
|
seed: opt(input.seed),
|
package/dist/commands/studio.js
CHANGED
|
@@ -61,6 +61,11 @@ const handler = (input) => Effect.gen(function* () {
|
|
|
61
61
|
resolve: {
|
|
62
62
|
dedupe: ["react", "react-dom", "effect", "effect-motion"],
|
|
63
63
|
},
|
|
64
|
+
// the studio app's JSX must not depend on the project's
|
|
65
|
+
// tsconfig — scaffolded projects set no `jsx`, and esbuild's
|
|
66
|
+
// fallback is the classic transform, which crashes with
|
|
67
|
+
// "React is not defined" before anything mounts
|
|
68
|
+
esbuild: { jsx: "automatic" },
|
|
64
69
|
plugins: [
|
|
65
70
|
{
|
|
66
71
|
name: "motion:project-watch",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-motion/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Command line for effect-motion:
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Command line for effect-motion: preview scenes with hot reload (studio) and render videos from motion.config.ts (render). Scaffold new projects with `pnpm create effect-motion`",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"dist",
|
|
34
|
-
"templates",
|
|
35
34
|
"studio-app"
|
|
36
35
|
],
|
|
37
36
|
"publishConfig": {
|
|
@@ -41,8 +40,8 @@
|
|
|
41
40
|
"@effect/platform-node": "4.0.0-beta.98",
|
|
42
41
|
"effect": "4.0.0-beta.98",
|
|
43
42
|
"vite": "^7.0.0",
|
|
44
|
-
"@effect-motion/export": "^0.
|
|
45
|
-
"effect-motion": "^0.
|
|
43
|
+
"@effect-motion/export": "^0.4.0",
|
|
44
|
+
"effect-motion": "^0.4.0"
|
|
46
45
|
},
|
|
47
46
|
"devDependencies": {
|
|
48
47
|
"@types/node": "^26.1.1",
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
"react-dom": "^19.2.0",
|
|
53
52
|
"typescript": "^7.0.2",
|
|
54
53
|
"vitest": "^4.1.10",
|
|
55
|
-
"@effect-motion/react": "^0.
|
|
54
|
+
"@effect-motion/react": "^0.4.0"
|
|
56
55
|
},
|
|
57
56
|
"scripts": {
|
|
58
57
|
"build": "tsc -p tsconfig.build.json",
|
package/studio-app/App.tsx
CHANGED
|
@@ -9,9 +9,21 @@ type TargetLike = {
|
|
|
9
9
|
readonly name?: string;
|
|
10
10
|
readonly scene?: string;
|
|
11
11
|
readonly settings?: Record<string, unknown>;
|
|
12
|
+
readonly player?: Record<string, unknown>;
|
|
12
13
|
};
|
|
13
14
|
type ConfigLike = { readonly targets?: ReadonlyArray<TargetLike> };
|
|
14
15
|
|
|
16
|
+
/** the PlayerProps subset a config's `player` block may set */
|
|
17
|
+
type PlayerOptions = Pick<
|
|
18
|
+
PlayerProps,
|
|
19
|
+
| "autoPlay"
|
|
20
|
+
| "defaultRepeatMode"
|
|
21
|
+
| "isInfinite"
|
|
22
|
+
| "prebufferedFrames"
|
|
23
|
+
| "bufferCapacity"
|
|
24
|
+
| "fps"
|
|
25
|
+
>;
|
|
26
|
+
|
|
15
27
|
type SceneEntry = {
|
|
16
28
|
/** picker identity: `target:<name>` for config targets, `file:<path>` otherwise */
|
|
17
29
|
readonly key: string;
|
|
@@ -20,6 +32,8 @@ type SceneEntry = {
|
|
|
20
32
|
readonly path: string;
|
|
21
33
|
readonly registered: boolean;
|
|
22
34
|
readonly settings: PlayerProps["settings"] | undefined;
|
|
35
|
+
/** studio preview options from the target's `player` block */
|
|
36
|
+
readonly player: PlayerOptions | undefined;
|
|
23
37
|
readonly load: () => Promise<unknown>;
|
|
24
38
|
};
|
|
25
39
|
|
|
@@ -59,6 +73,7 @@ const buildEntries = (config: ConfigLike | null): ReadonlyArray<SceneEntry> => {
|
|
|
59
73
|
// a registered scene previews with its target settings so the
|
|
60
74
|
// preview aspect matches the export
|
|
61
75
|
settings: target.settings as PlayerProps["settings"],
|
|
76
|
+
player: target.player as PlayerOptions | undefined,
|
|
62
77
|
load: globLoaders.get(path) ?? loadByAbsolutePath(path),
|
|
63
78
|
});
|
|
64
79
|
}
|
|
@@ -70,6 +85,7 @@ const buildEntries = (config: ConfigLike | null): ReadonlyArray<SceneEntry> => {
|
|
|
70
85
|
path,
|
|
71
86
|
registered: false,
|
|
72
87
|
settings: undefined,
|
|
88
|
+
player: undefined,
|
|
73
89
|
load,
|
|
74
90
|
});
|
|
75
91
|
}
|
|
@@ -174,17 +190,28 @@ export const App = () => {
|
|
|
174
190
|
<pre>{state.message}</pre>
|
|
175
191
|
</div>
|
|
176
192
|
)}
|
|
177
|
-
{state._tag === "ready" &&
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
{...
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
193
|
+
{state._tag === "ready" &&
|
|
194
|
+
(() => {
|
|
195
|
+
// player.fps is a preview override of the scene's own rate,
|
|
196
|
+
// so it lands in settings.frameRate (the Player's one clock,
|
|
197
|
+
// which wins over its fps prop); everything else is a plain
|
|
198
|
+
// Player prop and wins over the studio defaults
|
|
199
|
+
const { fps, ...playerProps } = entry?.player ?? {};
|
|
200
|
+
const settings =
|
|
201
|
+
fps !== undefined
|
|
202
|
+
? { ...entry?.settings, frameRate: fps }
|
|
203
|
+
: entry?.settings;
|
|
204
|
+
return (
|
|
205
|
+
<Player
|
|
206
|
+
key={state.key}
|
|
207
|
+
scene={state.scene}
|
|
208
|
+
autoPlay
|
|
209
|
+
defaultRepeatMode
|
|
210
|
+
{...(settings !== undefined ? { settings } : {})}
|
|
211
|
+
{...playerProps}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
})()}
|
|
188
215
|
{(state._tag === "idle" || state._tag === "loading") &&
|
|
189
216
|
entries.length === 0 && (
|
|
190
217
|
<p className="studio-empty">
|
package/dist/commands/init.d.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import * as Option from "effect/Option";
|
|
2
|
-
import { Command, Prompt } from "effect/unstable/cli";
|
|
3
|
-
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
4
|
-
import { MotionCliError } from "../MotionCliError.js";
|
|
5
|
-
export declare const initCommand: Command.Command<"init", {
|
|
6
|
-
readonly directory: Option.Option<string>;
|
|
7
|
-
readonly pm: Option.Option<"bun" | "npm" | "pnpm" | "yarn">;
|
|
8
|
-
readonly noInstall: boolean;
|
|
9
|
-
}, {}, MotionCliError, ChildProcessSpawner.ChildProcessSpawner | Prompt.Environment>;
|
package/dist/commands/init.js
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import * as Console from "effect/Console";
|
|
2
|
-
import * as Effect from "effect/Effect";
|
|
3
|
-
import * as Option from "effect/Option";
|
|
4
|
-
import { Path } from "effect/Path";
|
|
5
|
-
import { Argument, Command, Flag, Prompt } from "effect/unstable/cli";
|
|
6
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
7
|
-
import { MotionCliError } from "../MotionCliError.js";
|
|
8
|
-
import { ensureEmptyDir, resolveProjectDir, scaffoldProject, } from "../scaffold.js";
|
|
9
|
-
const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"];
|
|
10
|
-
/** The manager that invoked us (`pnpm create …` etc.), if detectable. */
|
|
11
|
-
const detectPackageManager = () => {
|
|
12
|
-
const agent = process.env.npm_config_user_agent ?? "";
|
|
13
|
-
return PACKAGE_MANAGERS.find((pm) => agent.startsWith(pm));
|
|
14
|
-
};
|
|
15
|
-
const initFlags = {
|
|
16
|
-
directory: Argument.string("directory").pipe(Argument.withDescription('Target directory ("." scaffolds into the current directory)'), Argument.optional),
|
|
17
|
-
pm: Flag.optional(Flag.choice("pm", PACKAGE_MANAGERS).pipe(Flag.withDescription("Package manager (skips the prompt)"))),
|
|
18
|
-
noInstall: Flag.boolean("no-install").pipe(Flag.withDescription("Skip dependency installation")),
|
|
19
|
-
};
|
|
20
|
-
const promptDirectory = Prompt.text({
|
|
21
|
-
message: 'Where should the project be created? ("." for the current directory)',
|
|
22
|
-
default: "my-motion-project",
|
|
23
|
-
});
|
|
24
|
-
const promptPackageManager = Effect.suspend(() => {
|
|
25
|
-
const detected = detectPackageManager();
|
|
26
|
-
// detected manager listed first so plain Enter picks it
|
|
27
|
-
const ordered = [
|
|
28
|
-
...(detected ? [detected] : []),
|
|
29
|
-
...PACKAGE_MANAGERS.filter((pm) => pm !== detected),
|
|
30
|
-
];
|
|
31
|
-
return Prompt.select({
|
|
32
|
-
message: "Which package manager?",
|
|
33
|
-
choices: ordered.map((pm) => ({ title: pm, value: pm })),
|
|
34
|
-
});
|
|
35
|
-
});
|
|
36
|
-
const runInstall = (pm, dir) => Effect.gen(function* () {
|
|
37
|
-
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
38
|
-
const command = ChildProcess.make(pm, ["install"], {
|
|
39
|
-
cwd: dir,
|
|
40
|
-
stdin: "inherit",
|
|
41
|
-
stdout: "inherit",
|
|
42
|
-
stderr: "inherit",
|
|
43
|
-
});
|
|
44
|
-
yield* Effect.scoped(Effect.gen(function* () {
|
|
45
|
-
const handle = yield* spawner.spawn(command);
|
|
46
|
-
const code = yield* handle.exitCode;
|
|
47
|
-
if (code !== 0) {
|
|
48
|
-
return yield* new MotionCliError({
|
|
49
|
-
reason: "InstallFailed",
|
|
50
|
-
message: `${pm} install exited with code ${code} in ${dir} — run it manually`,
|
|
51
|
-
cause: code,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
})).pipe(Effect.catchTag("PlatformError", (cause) => Effect.fail(new MotionCliError({
|
|
55
|
-
reason: "InstallFailed",
|
|
56
|
-
message: `could not run "${pm} install" in ${dir} — is ${pm} installed?`,
|
|
57
|
-
cause,
|
|
58
|
-
}))));
|
|
59
|
-
});
|
|
60
|
-
const handler = (input) => Effect.gen(function* () {
|
|
61
|
-
const path = yield* Path;
|
|
62
|
-
const cwd = process.cwd();
|
|
63
|
-
const dirInput = Option.getOrUndefined(input.directory) ?? (yield* promptDirectory);
|
|
64
|
-
const { dir, name } = resolveProjectDir(path, cwd, dirInput);
|
|
65
|
-
yield* ensureEmptyDir(dir);
|
|
66
|
-
const pm = Option.getOrUndefined(input.pm) ?? (yield* promptPackageManager);
|
|
67
|
-
yield* scaffoldProject(dir, name);
|
|
68
|
-
yield* Console.log(`Scaffolded ${name} in ${dir}`);
|
|
69
|
-
if (input.noInstall) {
|
|
70
|
-
yield* Console.log([
|
|
71
|
-
"",
|
|
72
|
-
"Next steps:",
|
|
73
|
-
dir === cwd ? "" : ` cd ${path.relative(cwd, dir)}`,
|
|
74
|
-
` ${pm} install`,
|
|
75
|
-
` ${pm === "npm" ? "npm run" : pm} studio`,
|
|
76
|
-
]
|
|
77
|
-
.filter((line) => line !== "")
|
|
78
|
-
.join("\n"));
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
yield* runInstall(pm, dir);
|
|
82
|
-
yield* Console.log([
|
|
83
|
-
"",
|
|
84
|
-
`${name} is ready.`,
|
|
85
|
-
dir === cwd ? "" : ` cd ${path.relative(cwd, dir)}`,
|
|
86
|
-
` ${pm === "npm" ? "npm run" : pm} studio # preview scenes with hot reload`,
|
|
87
|
-
` ${pm === "npm" ? "npm run" : pm} render # render targets from motion.config.ts`,
|
|
88
|
-
]
|
|
89
|
-
.filter((line) => line !== "")
|
|
90
|
-
.join("\n"));
|
|
91
|
-
}).pipe(
|
|
92
|
-
// ctrl-c in a prompt is an interruption, not a failure
|
|
93
|
-
Effect.catchTag("QuitError", () => Effect.interrupt));
|
|
94
|
-
export const initCommand = Command.make("init", initFlags, handler).pipe(Command.withDescription("Scaffold a new effect-motion project"));
|
package/dist/pins.d.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Exact versions a scaffolded project is pinned to — the set this CLI
|
|
3
|
-
* release was built and tested against. The effect pin is a determinism
|
|
4
|
-
* invariant (upgrading effect can change seeded random sequences), so
|
|
5
|
-
* scaffolds never use ranges or `latest`. Updated by the CLI's own release
|
|
6
|
-
* process.
|
|
7
|
-
*/
|
|
8
|
-
export declare const PINS: {
|
|
9
|
-
readonly effect: "4.0.0-beta.98";
|
|
10
|
-
readonly "effect-motion": "0.2.0";
|
|
11
|
-
readonly "@effect-motion/react": "0.2.0";
|
|
12
|
-
readonly "@effect-motion/export": "0.2.0";
|
|
13
|
-
readonly "@effect-motion/cli": "0.1.0";
|
|
14
|
-
};
|
|
15
|
-
/** Non-determinism-critical companions; ranges are fine here. */
|
|
16
|
-
export declare const COMPANIONS: {
|
|
17
|
-
readonly react: "^19.2.0";
|
|
18
|
-
readonly "react-dom": "^19.2.0";
|
|
19
|
-
readonly typescript: "^7.0.2";
|
|
20
|
-
readonly "@types/react": "^19.2.0";
|
|
21
|
-
readonly "@types/react-dom": "^19.2.0";
|
|
22
|
-
readonly "@types/node": "^26.1.1";
|
|
23
|
-
};
|
package/dist/pins.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Exact versions a scaffolded project is pinned to — the set this CLI
|
|
3
|
-
* release was built and tested against. The effect pin is a determinism
|
|
4
|
-
* invariant (upgrading effect can change seeded random sequences), so
|
|
5
|
-
* scaffolds never use ranges or `latest`. Updated by the CLI's own release
|
|
6
|
-
* process.
|
|
7
|
-
*/
|
|
8
|
-
export const PINS = {
|
|
9
|
-
effect: "4.0.0-beta.98",
|
|
10
|
-
"effect-motion": "0.2.0",
|
|
11
|
-
"@effect-motion/react": "0.2.0",
|
|
12
|
-
"@effect-motion/export": "0.2.0",
|
|
13
|
-
"@effect-motion/cli": "0.1.0",
|
|
14
|
-
};
|
|
15
|
-
/** Non-determinism-critical companions; ranges are fine here. */
|
|
16
|
-
export const COMPANIONS = {
|
|
17
|
-
react: "^19.2.0",
|
|
18
|
-
"react-dom": "^19.2.0",
|
|
19
|
-
typescript: "^7.0.2",
|
|
20
|
-
"@types/react": "^19.2.0",
|
|
21
|
-
"@types/react-dom": "^19.2.0",
|
|
22
|
-
"@types/node": "^26.1.1",
|
|
23
|
-
};
|
package/dist/scaffold.d.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import * as Effect from "effect/Effect";
|
|
2
|
-
import { FileSystem } from "effect/FileSystem";
|
|
3
|
-
import { Path } from "effect/Path";
|
|
4
|
-
import { MotionCliError } from "./MotionCliError.js";
|
|
5
|
-
/**
|
|
6
|
-
* The non-interactive core of `motion init`: everything except the prompts,
|
|
7
|
-
* so tests can drive it directly. Copies templates/default into the target
|
|
8
|
-
* directory and generates package.json from the pinned versions.
|
|
9
|
-
*/
|
|
10
|
-
/** Directory of the shipped templates (dist/scaffold.js → ../templates). */
|
|
11
|
-
export declare const templatesDir: (path: Path) => string;
|
|
12
|
-
/** `.` means "here"; the project is named after the resolved directory. */
|
|
13
|
-
export declare const resolveProjectDir: (path: Path, cwd: string, input: string) => {
|
|
14
|
-
dir: string;
|
|
15
|
-
name: string;
|
|
16
|
-
};
|
|
17
|
-
/** Non-empty means anything but dotfiles (a fresh `git init` is fine). */
|
|
18
|
-
export declare const ensureEmptyDir: (dir: string) => Effect.Effect<undefined, MotionCliError, FileSystem>;
|
|
19
|
-
/** Copy the template tree + write the generated package.json. */
|
|
20
|
-
export declare const scaffoldProject: (dir: string, name: string) => Effect.Effect<void, MotionCliError, FileSystem | Path>;
|
package/dist/scaffold.js
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import * as Effect from "effect/Effect";
|
|
2
|
-
import { FileSystem } from "effect/FileSystem";
|
|
3
|
-
import { Path } from "effect/Path";
|
|
4
|
-
import { MotionCliError } from "./MotionCliError.js";
|
|
5
|
-
import { COMPANIONS, PINS } from "./pins.js";
|
|
6
|
-
/**
|
|
7
|
-
* The non-interactive core of `motion init`: everything except the prompts,
|
|
8
|
-
* so tests can drive it directly. Copies templates/default into the target
|
|
9
|
-
* directory and generates package.json from the pinned versions.
|
|
10
|
-
*/
|
|
11
|
-
/** Directory of the shipped templates (dist/scaffold.js → ../templates). */
|
|
12
|
-
export const templatesDir = (path) => path.join(path.dirname(new URL(import.meta.url).pathname), "..", "templates", "default");
|
|
13
|
-
/** `.` means "here"; the project is named after the resolved directory. */
|
|
14
|
-
export const resolveProjectDir = (path, cwd, input) => {
|
|
15
|
-
const dir = path.resolve(cwd, input);
|
|
16
|
-
return { dir, name: path.basename(dir) };
|
|
17
|
-
};
|
|
18
|
-
/** Non-empty means anything but dotfiles (a fresh `git init` is fine). */
|
|
19
|
-
export const ensureEmptyDir = (dir) => Effect.gen(function* () {
|
|
20
|
-
const fs = yield* FileSystem;
|
|
21
|
-
if (!(yield* fs.exists(dir)))
|
|
22
|
-
return;
|
|
23
|
-
const entries = yield* fs.readDirectory(dir);
|
|
24
|
-
const meaningful = entries.filter((entry) => !entry.startsWith("."));
|
|
25
|
-
if (meaningful.length > 0) {
|
|
26
|
-
return yield* new MotionCliError({
|
|
27
|
-
reason: "ScaffoldTargetNotEmpty",
|
|
28
|
-
message: `${dir} is not empty (found ${meaningful.slice(0, 3).join(", ")}${meaningful.length > 3 ? ", …" : ""}) — choose an empty or new directory`,
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
}).pipe(wrapFsError("ScaffoldFailed", `could not inspect ${dir}`));
|
|
32
|
-
const wrapFsError = (reason, message) => (effect) => Effect.mapError(effect, (cause) => cause instanceof MotionCliError
|
|
33
|
-
? cause
|
|
34
|
-
: new MotionCliError({ reason, message, cause }));
|
|
35
|
-
const packageJson = (name) => `${JSON.stringify({
|
|
36
|
-
name,
|
|
37
|
-
private: true,
|
|
38
|
-
version: "0.0.0",
|
|
39
|
-
type: "module",
|
|
40
|
-
scripts: {
|
|
41
|
-
studio: "motion studio",
|
|
42
|
-
render: "motion render",
|
|
43
|
-
},
|
|
44
|
-
dependencies: {
|
|
45
|
-
"@effect-motion/export": PINS["@effect-motion/export"],
|
|
46
|
-
"@effect-motion/react": PINS["@effect-motion/react"],
|
|
47
|
-
effect: PINS.effect,
|
|
48
|
-
"effect-motion": PINS["effect-motion"],
|
|
49
|
-
react: COMPANIONS.react,
|
|
50
|
-
"react-dom": COMPANIONS["react-dom"],
|
|
51
|
-
},
|
|
52
|
-
devDependencies: {
|
|
53
|
-
"@effect-motion/cli": PINS["@effect-motion/cli"],
|
|
54
|
-
"@types/node": COMPANIONS["@types/node"],
|
|
55
|
-
"@types/react": COMPANIONS["@types/react"],
|
|
56
|
-
"@types/react-dom": COMPANIONS["@types/react-dom"],
|
|
57
|
-
typescript: COMPANIONS.typescript,
|
|
58
|
-
},
|
|
59
|
-
}, null, "\t")}\n`;
|
|
60
|
-
/** Copy the template tree + write the generated package.json. */
|
|
61
|
-
export const scaffoldProject = (dir, name) => Effect.gen(function* () {
|
|
62
|
-
const fs = yield* FileSystem;
|
|
63
|
-
const path = yield* Path;
|
|
64
|
-
const templates = templatesDir(path);
|
|
65
|
-
yield* fs.makeDirectory(dir, { recursive: true });
|
|
66
|
-
yield* copyTree(fs, path, templates, dir);
|
|
67
|
-
// npm mangles nested .gitignore/package.json files in published
|
|
68
|
-
// tarballs, so both ship outside the template tree
|
|
69
|
-
yield* fs.writeFileString(path.join(dir, "package.json"), packageJson(name));
|
|
70
|
-
yield* fs.rename(path.join(dir, "_gitignore"), path.join(dir, ".gitignore"));
|
|
71
|
-
}).pipe(wrapFsError("ScaffoldFailed", `could not scaffold ${dir}`));
|
|
72
|
-
const copyTree = (fs, path, from, to) => Effect.gen(function* () {
|
|
73
|
-
const entries = yield* fs.readDirectory(from);
|
|
74
|
-
for (const entry of entries) {
|
|
75
|
-
const src = path.join(from, entry);
|
|
76
|
-
const dst = path.join(to, entry);
|
|
77
|
-
const info = yield* fs.stat(src);
|
|
78
|
-
if (info.type === "Directory") {
|
|
79
|
-
yield* fs.makeDirectory(dst, { recursive: true });
|
|
80
|
-
yield* copyTree(fs, path, src, dst);
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
yield* fs.copyFile(src, dst);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
});
|
|
@@ -1,59 +0,0 @@
|
|
|
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.
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
}
|