@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.
- package/README.md +70 -0
- package/dist/Config.d.ts +78 -0
- package/dist/Config.js +67 -0
- package/dist/ConfigLoader.d.ts +15 -0
- package/dist/ConfigLoader.js +58 -0
- package/dist/MotionCliError.d.ts +25 -0
- package/dist/MotionCliError.js +21 -0
- package/dist/ViteLoader.d.ts +15 -0
- package/dist/ViteLoader.js +35 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +11 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +32 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +94 -0
- package/dist/commands/render.d.ts +18 -0
- package/dist/commands/render.js +159 -0
- package/dist/commands/studio.d.ts +10 -0
- package/dist/commands/studio.js +106 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/pins.d.ts +23 -0
- package/dist/pins.js +23 -0
- package/dist/scaffold.d.ts +20 -0
- package/dist/scaffold.js +86 -0
- package/package.json +63 -0
- package/studio-app/App.tsx +197 -0
- package/studio-app/env.d.ts +1 -0
- package/studio-app/index.html +96 -0
- package/studio-app/main.tsx +11 -0
- package/studio-app/project.ts +3 -0
- package/templates/default/AGENTS.md +59 -0
- package/templates/default/_gitignore +3 -0
- package/templates/default/motion.config.ts +20 -0
- package/templates/default/src/assets/.gitkeep +0 -0
- package/templates/default/src/main.ts +12 -0
- package/templates/default/src/scenes/hello-world.ts +15 -0
- package/templates/default/tsconfig.json +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @effect-motion/cli
|
|
2
|
+
|
|
3
|
+
Command line for [effect-motion](https://github.com/julia-script/effect-motion): scaffold a project, preview scenes in the browser, render videos — all driven by one `motion.config.ts`.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @effect-motion/cli init # scaffold a project
|
|
7
|
+
motion studio # preview scenes with hot reload
|
|
8
|
+
motion render # render every target to MP4
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## init
|
|
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.
|
|
30
|
+
|
|
31
|
+
## motion.config.ts
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { defineConfig } from "@effect-motion/cli";
|
|
35
|
+
|
|
36
|
+
export default defineConfig({
|
|
37
|
+
targets: [
|
|
38
|
+
{
|
|
39
|
+
name: "hello-world", // unique; doubles as the output basename
|
|
40
|
+
scene: "./src/scenes/hello-world.ts", // module exporting `scene`
|
|
41
|
+
settings: { width: 1920, height: 1080, frameRate: 60, dpr: 1 },
|
|
42
|
+
output: "./output", // a DIRECTORY — file name is derived
|
|
43
|
+
// format: "mp4" // default (v1: mp4 only)
|
|
44
|
+
// frames: 600 // frame cap; required for infinite scenes
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The output path is always `<output>/<name>.<format>` — never specified by hand. `settings` is the runner's settings subset plus `dpr` (supersampling: output pixels = scene dimensions × dpr, authored coordinates unchanged).
|
|
51
|
+
|
|
52
|
+
## studio
|
|
53
|
+
|
|
54
|
+
`motion studio` serves the `@effect-motion/react` Player over your scenes with hot reload (edits full-reload; playback restarts from frame 0). The picker lists every config target **plus** any unregistered `src/scenes/*.ts` — preview never requires registration. Registered scenes preview with their target `settings`, so the preview aspect matches the export. `--port`/`--host` pass through to Vite; the app is generated into `.motion/studio/` (gitignored by the scaffold).
|
|
55
|
+
|
|
56
|
+
Note: the studio previews through the SVG DOM renderer while `render` rasterizes through ThorVG — output is normally identical, but font fallback details can differ. The known upgrade path is a ThorVG-WASM preview sink.
|
|
57
|
+
|
|
58
|
+
## render
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
motion render # all targets from the nearest motion.config.ts
|
|
62
|
+
motion render hello-world # just this target
|
|
63
|
+
motion render --config ../m.config.ts # explicit config (tsc -p style)
|
|
64
|
+
motion render ./src/scenes/foo.ts # configless: one scene file, default settings
|
|
65
|
+
motion render --fps 30 --dpr 2 --out-dir ./out # flags beat config beat defaults
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Flags: `--width --height --fps --dpr --seed --max-frames --frames --out-dir --format --config`. Targets render sequentially; a failing target doesn't stop the rest (non-zero exit + per-target summary at the end). Errors print a single message naming the offender — add `--verbose` for the full cause chain.
|
|
69
|
+
|
|
70
|
+
Encoding uses the ffmpeg build bundled via `ffmpeg-static` (H.264/yuv420p MP4, no system ffmpeg needed). That binary is GPL-licensed; it is invoked over a process boundary and this package remains MIT.
|
package/dist/Config.d.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type * as Runner from "effect-motion/Runner";
|
|
2
|
+
/**
|
|
3
|
+
* The `motion.config.ts` contract. This module is intentionally
|
|
4
|
+
* browser-safe (the studio app imports the user's config directly), so it
|
|
5
|
+
* must stay free of Node-only imports — loading/discovery live in
|
|
6
|
+
* ConfigLoader.ts.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Per-target render settings: the Runner `Settings` subset an export can
|
|
10
|
+
* honor, plus `dpr`. From the author's perspective dpr is a rendering
|
|
11
|
+
* setting like width/height; the render command maps it to the export
|
|
12
|
+
* package's supersampling option.
|
|
13
|
+
*/
|
|
14
|
+
export interface TargetSettings {
|
|
15
|
+
readonly width?: number;
|
|
16
|
+
readonly height?: number;
|
|
17
|
+
readonly frameRate?: number;
|
|
18
|
+
readonly seed?: Runner.Seed;
|
|
19
|
+
readonly maxFrames?: number;
|
|
20
|
+
readonly backgroundColor?: Runner.Settings["backgroundColor"];
|
|
21
|
+
readonly dpr?: number;
|
|
22
|
+
}
|
|
23
|
+
/** v1 ships MP4 only; the field exists so more containers slot in later. */
|
|
24
|
+
export type OutputFormat = "mp4";
|
|
25
|
+
export interface MotionTarget {
|
|
26
|
+
/** Unique per config — doubles as the output file basename. */
|
|
27
|
+
readonly name: string;
|
|
28
|
+
/** Path to a module exporting `scene`, relative to the config file. */
|
|
29
|
+
readonly scene: string;
|
|
30
|
+
readonly settings?: TargetSettings;
|
|
31
|
+
/** Output DIRECTORY (never a file), relative to the config file. */
|
|
32
|
+
readonly output?: string;
|
|
33
|
+
readonly format?: OutputFormat;
|
|
34
|
+
/** Frame cap — required in practice for an infinite scene. */
|
|
35
|
+
readonly frames?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface MotionConfig {
|
|
38
|
+
readonly targets: ReadonlyArray<MotionTarget>;
|
|
39
|
+
}
|
|
40
|
+
/** Identity helper that types `motion.config.ts` (vite/vitest convention). */
|
|
41
|
+
export declare const defineConfig: (config: MotionConfig) => MotionConfig;
|
|
42
|
+
export declare const DEFAULT_OUTPUT_DIR = "./output";
|
|
43
|
+
export declare const DEFAULT_FORMAT: OutputFormat;
|
|
44
|
+
/**
|
|
45
|
+
* Validate the default export of a loaded config module. Plain structural
|
|
46
|
+
* checks (not Schema): the config is authored in TS against `MotionConfig`,
|
|
47
|
+
* so this only guards the untyped escape hatches (JS configs, `as any`).
|
|
48
|
+
*/
|
|
49
|
+
export declare const validateConfig: (value: unknown, configPath: string) => MotionConfig;
|
|
50
|
+
/** Flag values a `motion render` invocation can lay over a target. */
|
|
51
|
+
export interface RenderOverrides {
|
|
52
|
+
readonly width?: number;
|
|
53
|
+
readonly height?: number;
|
|
54
|
+
readonly frameRate?: number;
|
|
55
|
+
readonly dpr?: number;
|
|
56
|
+
readonly seed?: Runner.Seed;
|
|
57
|
+
readonly maxFrames?: number;
|
|
58
|
+
readonly frames?: number;
|
|
59
|
+
readonly outDir?: string;
|
|
60
|
+
readonly format?: OutputFormat;
|
|
61
|
+
}
|
|
62
|
+
/** A target with overrides applied and output location derived. */
|
|
63
|
+
export interface ResolvedTarget {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly scene: string;
|
|
66
|
+
readonly settings: TargetSettings;
|
|
67
|
+
readonly frames: number | undefined;
|
|
68
|
+
/** Output directory (still config-relative — the caller resolves). */
|
|
69
|
+
readonly outDir: string;
|
|
70
|
+
/** Derived file name: `<name>.<format>` — never user-specified. */
|
|
71
|
+
readonly fileName: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Precedence, highest wins: CLI flags → target config → library defaults.
|
|
75
|
+
* Library defaults are NOT materialized here — leaving fields undefined
|
|
76
|
+
* lets the Runner's own defaults apply, so there is one source of truth.
|
|
77
|
+
*/
|
|
78
|
+
export declare const resolveTarget: (target: MotionTarget, overrides?: RenderOverrides) => ResolvedTarget;
|
package/dist/Config.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { MotionCliError } from "./MotionCliError.js";
|
|
2
|
+
/** Identity helper that types `motion.config.ts` (vite/vitest convention). */
|
|
3
|
+
export const defineConfig = (config) => config;
|
|
4
|
+
export const DEFAULT_OUTPUT_DIR = "./output";
|
|
5
|
+
export const DEFAULT_FORMAT = "mp4";
|
|
6
|
+
/**
|
|
7
|
+
* Validate the default export of a loaded config module. Plain structural
|
|
8
|
+
* checks (not Schema): the config is authored in TS against `MotionConfig`,
|
|
9
|
+
* so this only guards the untyped escape hatches (JS configs, `as any`).
|
|
10
|
+
*/
|
|
11
|
+
export const validateConfig = (value, configPath) => {
|
|
12
|
+
const fail = (problem) => {
|
|
13
|
+
throw new MotionCliError({
|
|
14
|
+
reason: "ConfigInvalid",
|
|
15
|
+
message: `${configPath}: ${problem}`,
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
if (typeof value !== "object" || value === null) {
|
|
19
|
+
return fail("default export is not a config object (did you forget `export default defineConfig({...})`?)");
|
|
20
|
+
}
|
|
21
|
+
const config = value;
|
|
22
|
+
if (!Array.isArray(config.targets)) {
|
|
23
|
+
return fail("config has no `targets` array");
|
|
24
|
+
}
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (const [i, target] of config.targets.entries()) {
|
|
27
|
+
if (typeof target !== "object" || target === null) {
|
|
28
|
+
return fail(`targets[${i}] is not an object`);
|
|
29
|
+
}
|
|
30
|
+
if (typeof target.name !== "string" || target.name.length === 0) {
|
|
31
|
+
return fail(`targets[${i}] is missing a \`name\``);
|
|
32
|
+
}
|
|
33
|
+
if (typeof target.scene !== "string" || target.scene.length === 0) {
|
|
34
|
+
return fail(`target "${target.name}" is missing a \`scene\` path`);
|
|
35
|
+
}
|
|
36
|
+
if (seen.has(target.name)) {
|
|
37
|
+
return fail(`duplicate target name "${target.name}" (names double as output filenames, so they must be unique)`);
|
|
38
|
+
}
|
|
39
|
+
seen.add(target.name);
|
|
40
|
+
if (target.format !== undefined && target.format !== "mp4") {
|
|
41
|
+
return fail(`target "${target.name}" has unsupported format "${target.format}" (v1 supports "mp4")`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return config;
|
|
45
|
+
};
|
|
46
|
+
const definedEntries = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
47
|
+
/**
|
|
48
|
+
* Precedence, highest wins: CLI flags → target config → library defaults.
|
|
49
|
+
* Library defaults are NOT materialized here — leaving fields undefined
|
|
50
|
+
* lets the Runner's own defaults apply, so there is one source of truth.
|
|
51
|
+
*/
|
|
52
|
+
export const resolveTarget = (target, overrides = {}) => {
|
|
53
|
+
const { frames: overrideFrames, outDir, format, ...settingsOverrides } = overrides;
|
|
54
|
+
const settings = {
|
|
55
|
+
...definedEntries(target.settings ?? {}),
|
|
56
|
+
...definedEntries(settingsOverrides),
|
|
57
|
+
};
|
|
58
|
+
const resolvedFormat = format ?? target.format ?? DEFAULT_FORMAT;
|
|
59
|
+
return {
|
|
60
|
+
name: target.name,
|
|
61
|
+
scene: target.scene,
|
|
62
|
+
settings,
|
|
63
|
+
frames: overrideFrames ?? target.frames,
|
|
64
|
+
outDir: outDir ?? target.output ?? DEFAULT_OUTPUT_DIR,
|
|
65
|
+
fileName: `${target.name}.${resolvedFormat}`,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { FileSystem } from "effect/FileSystem";
|
|
3
|
+
import { Path } from "effect/Path";
|
|
4
|
+
import { type MotionConfig } from "./Config.js";
|
|
5
|
+
import { MotionCliError } from "./MotionCliError.js";
|
|
6
|
+
import type { ViteLoader } from "./ViteLoader.js";
|
|
7
|
+
export declare const CONFIG_FILE = "motion.config.ts";
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the config path: an explicit `--config` wins; otherwise walk up
|
|
10
|
+
* from `cwd` to the nearest motion.config.ts (tsc `-p` semantics). Fails
|
|
11
|
+
* with ConfigNotFound naming both escape hatches.
|
|
12
|
+
*/
|
|
13
|
+
export declare const findConfig: (cwd: string, explicit?: string) => Effect.Effect<string, MotionCliError, FileSystem | Path>;
|
|
14
|
+
/** Load + validate a config module through the shared Vite loader. */
|
|
15
|
+
export declare const loadConfig: (loader: ViteLoader, configPath: string) => Effect.Effect<MotionConfig, MotionCliError>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { FileSystem } from "effect/FileSystem";
|
|
3
|
+
import { Path } from "effect/Path";
|
|
4
|
+
import { validateConfig } from "./Config.js";
|
|
5
|
+
import { MotionCliError } from "./MotionCliError.js";
|
|
6
|
+
export const CONFIG_FILE = "motion.config.ts";
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the config path: an explicit `--config` wins; otherwise walk up
|
|
9
|
+
* from `cwd` to the nearest motion.config.ts (tsc `-p` semantics). Fails
|
|
10
|
+
* with ConfigNotFound naming both escape hatches.
|
|
11
|
+
*/
|
|
12
|
+
export const findConfig = (cwd, explicit) => Effect.gen(function* () {
|
|
13
|
+
const fs = yield* FileSystem;
|
|
14
|
+
const path = yield* Path;
|
|
15
|
+
if (explicit !== undefined) {
|
|
16
|
+
const resolved = path.resolve(cwd, explicit);
|
|
17
|
+
const exists = yield* orFalse(fs.exists(resolved));
|
|
18
|
+
if (!exists) {
|
|
19
|
+
return yield* new MotionCliError({
|
|
20
|
+
reason: "ConfigNotFound",
|
|
21
|
+
message: `config file not found: ${resolved}`,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return resolved;
|
|
25
|
+
}
|
|
26
|
+
let dir = path.resolve(cwd);
|
|
27
|
+
while (true) {
|
|
28
|
+
const candidate = path.join(dir, CONFIG_FILE);
|
|
29
|
+
if (yield* orFalse(fs.exists(candidate)))
|
|
30
|
+
return candidate;
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent === dir)
|
|
33
|
+
break;
|
|
34
|
+
dir = parent;
|
|
35
|
+
}
|
|
36
|
+
return yield* new MotionCliError({
|
|
37
|
+
reason: "ConfigNotFound",
|
|
38
|
+
message: `no ${CONFIG_FILE} found from ${cwd} upward — ` +
|
|
39
|
+
`create one (export default defineConfig({ targets: [...] })), pass --config <path>, ` +
|
|
40
|
+
`or pass a scene file directly (motion render ./src/scenes/foo.ts)`,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
// fs.exists fails on permission errors etc. — treat those as "not here"
|
|
44
|
+
const orFalse = (effect) => Effect.catchCause(effect, () => Effect.succeed(false));
|
|
45
|
+
/** Load + validate a config module through the shared Vite loader. */
|
|
46
|
+
export const loadConfig = (loader, configPath) => Effect.gen(function* () {
|
|
47
|
+
const module_ = yield* loader.load(configPath);
|
|
48
|
+
return yield* Effect.try({
|
|
49
|
+
try: () => validateConfig(module_.default, configPath),
|
|
50
|
+
catch: (error) => error instanceof MotionCliError
|
|
51
|
+
? error
|
|
52
|
+
: new MotionCliError({
|
|
53
|
+
reason: "ConfigInvalid",
|
|
54
|
+
message: `${configPath}: config validation crashed`,
|
|
55
|
+
cause: error,
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every failure mode of the CLI, as a `reason` union on a single tagged
|
|
3
|
+
* error. One type keeps the error channel a single name in every command
|
|
4
|
+
* signature; adding a failure mode is a union-member addition handled
|
|
5
|
+
* exhaustively at exactly one place (the top-level reporter in bin.ts).
|
|
6
|
+
*/
|
|
7
|
+
export type MotionCliReason = "ConfigNotFound" | "ConfigInvalid" | "SceneLoadFailed" | "UnknownTarget" | "ScaffoldTargetNotEmpty" | "ScaffoldFailed" | "InstallFailed" | "RenderFailed" | "StudioFailed";
|
|
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
|
+
readonly _tag: "MotionCliError";
|
|
10
|
+
} & Readonly<A>;
|
|
11
|
+
/**
|
|
12
|
+
* The one error type of `@effect-motion/cli`: either wraps an upstream
|
|
13
|
+
* failure (`cause` carries it) or states a custom one. `message` MUST name
|
|
14
|
+
* the offender — the file, target, or path that failed — because it is the
|
|
15
|
+
* only line shown without `--verbose`.
|
|
16
|
+
*/
|
|
17
|
+
export declare class MotionCliError extends MotionCliError_base<{
|
|
18
|
+
readonly reason: MotionCliReason;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly cause?: unknown;
|
|
21
|
+
}> {
|
|
22
|
+
}
|
|
23
|
+
/** Render an error for the terminal: message always, cause chain on verbose. */
|
|
24
|
+
export declare const renderForTerminal: (error: MotionCliError, verbose: boolean) => string;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
/**
|
|
3
|
+
* The one error type of `@effect-motion/cli`: either wraps an upstream
|
|
4
|
+
* failure (`cause` carries it) or states a custom one. `message` MUST name
|
|
5
|
+
* the offender — the file, target, or path that failed — because it is the
|
|
6
|
+
* only line shown without `--verbose`.
|
|
7
|
+
*/
|
|
8
|
+
export class MotionCliError extends Data.TaggedError("MotionCliError") {
|
|
9
|
+
}
|
|
10
|
+
/** Render an error for the terminal: message always, cause chain on verbose. */
|
|
11
|
+
export const renderForTerminal = (error, verbose) => {
|
|
12
|
+
const lines = [`error(${error.reason}): ${error.message}`];
|
|
13
|
+
if (verbose) {
|
|
14
|
+
let cause = error.cause;
|
|
15
|
+
while (cause !== undefined && cause !== null) {
|
|
16
|
+
lines.push(`caused by: ${cause instanceof Error ? (cause.stack ?? cause.message) : String(cause)}`);
|
|
17
|
+
cause = cause instanceof Error ? cause.cause : undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return lines.join("\n");
|
|
21
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import type * as Scope from "effect/Scope";
|
|
3
|
+
import type { ViteDevServer } from "vite";
|
|
4
|
+
import { MotionCliError } from "./MotionCliError.js";
|
|
5
|
+
/**
|
|
6
|
+
* The one TypeScript loader of the CLI: a Vite server in middleware mode
|
|
7
|
+
* whose `ssrLoadModule` executes user TS (config and scenes) in Node.
|
|
8
|
+
* Studio uses a Vite server too, so preview and render resolve the same
|
|
9
|
+
* module graph — the design's single-resolver invariant.
|
|
10
|
+
*/
|
|
11
|
+
export interface ViteLoader {
|
|
12
|
+
readonly load: (file: string) => Effect.Effect<Record<string, unknown>, MotionCliError>;
|
|
13
|
+
readonly server: ViteDevServer;
|
|
14
|
+
}
|
|
15
|
+
export declare const makeViteLoader: (root: string) => Effect.Effect<ViteLoader, MotionCliError, Scope.Scope>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { MotionCliError } from "./MotionCliError.js";
|
|
3
|
+
export const makeViteLoader = (root) => Effect.acquireRelease(Effect.tryPromise({
|
|
4
|
+
try: async () => {
|
|
5
|
+
const { createServer } = await import("vite");
|
|
6
|
+
return createServer({
|
|
7
|
+
root,
|
|
8
|
+
configFile: false,
|
|
9
|
+
logLevel: "error",
|
|
10
|
+
appType: "custom",
|
|
11
|
+
server: {
|
|
12
|
+
middlewareMode: true,
|
|
13
|
+
// a load-only server: no HMR socket, no file watching
|
|
14
|
+
hmr: false,
|
|
15
|
+
watch: null,
|
|
16
|
+
},
|
|
17
|
+
optimizeDeps: { noDiscovery: true },
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
catch: (cause) => new MotionCliError({
|
|
21
|
+
reason: "SceneLoadFailed",
|
|
22
|
+
message: `could not start the module loader (vite) in ${root}`,
|
|
23
|
+
cause,
|
|
24
|
+
}),
|
|
25
|
+
}), (server) => Effect.promise(() => server.close())).pipe(Effect.map((server) => ({
|
|
26
|
+
server,
|
|
27
|
+
load: (file) => Effect.tryPromise({
|
|
28
|
+
try: () => server.ssrLoadModule(file),
|
|
29
|
+
catch: (cause) => new MotionCliError({
|
|
30
|
+
reason: "SceneLoadFailed",
|
|
31
|
+
message: `failed to load ${file}`,
|
|
32
|
+
cause,
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
})));
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { Command } from "effect/unstable/cli";
|
|
5
|
+
import { CLI_VERSION, reportErrors, rootCommand } from "./cli.js";
|
|
6
|
+
// read pre-parse so the reporter works even when parsing itself fails
|
|
7
|
+
const verbose = process.argv.includes("--verbose");
|
|
8
|
+
const program = reportErrors(Command.run(rootCommand, { version: CLI_VERSION }), verbose);
|
|
9
|
+
// every typed failure is handled by reportErrors, so the default reporter
|
|
10
|
+
// only ever fires for defects — bugs in the CLI itself, where a trace is right
|
|
11
|
+
NodeRuntime.runMain(Effect.provide(program, NodeServices.layer));
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { CliError, Command } from "effect/unstable/cli";
|
|
3
|
+
import { type MotionCliError } from "./MotionCliError.js";
|
|
4
|
+
export declare const rootCommand: Command.Command<"motion", {}, {}, MotionCliError, never>;
|
|
5
|
+
export declare const CLI_VERSION: "0.1.0";
|
|
6
|
+
/**
|
|
7
|
+
* The single exhaustive failure boundary (design D3a): MotionCliError
|
|
8
|
+
* prints its message (cause chain under --verbose) and sets a non-zero
|
|
9
|
+
* exit; Command API errors print their diagnostic (help output was already
|
|
10
|
+
* rendered for ShowHelp). Anything past this boundary is a defect.
|
|
11
|
+
*/
|
|
12
|
+
export declare const reportErrors: <A, R>(program: Effect.Effect<A, MotionCliError | CliError.CliError, R>, verbose: boolean) => Effect.Effect<A | undefined, never, R>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as Console from "effect/Console";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import { CliError, Command, Flag, GlobalFlag } from "effect/unstable/cli";
|
|
4
|
+
import { initCommand } from "./commands/init.js";
|
|
5
|
+
import { renderCommand } from "./commands/render.js";
|
|
6
|
+
import { studioCommand } from "./commands/studio.js";
|
|
7
|
+
import { renderForTerminal } from "./MotionCliError.js";
|
|
8
|
+
import { PINS } from "./pins.js";
|
|
9
|
+
// registered globally so `--verbose` parses anywhere on the command line;
|
|
10
|
+
// the reporter reads argv directly because it sits outside handler context
|
|
11
|
+
const verboseFlag = GlobalFlag.setting("verbose")({
|
|
12
|
+
flag: Flag.boolean("verbose").pipe(Flag.withDescription("Print full error cause chains")),
|
|
13
|
+
});
|
|
14
|
+
export const rootCommand = Command.make("motion").pipe(Command.withDescription("effect-motion: scaffold projects, preview scenes, render videos"), Command.withSubcommands([initCommand, studioCommand, renderCommand]), Command.withGlobalFlags([verboseFlag]));
|
|
15
|
+
export const CLI_VERSION = PINS["@effect-motion/cli"];
|
|
16
|
+
/**
|
|
17
|
+
* The single exhaustive failure boundary (design D3a): MotionCliError
|
|
18
|
+
* prints its message (cause chain under --verbose) and sets a non-zero
|
|
19
|
+
* exit; Command API errors print their diagnostic (help output was already
|
|
20
|
+
* rendered for ShowHelp). Anything past this boundary is a defect.
|
|
21
|
+
*/
|
|
22
|
+
export const reportErrors = (program, verbose) => program.pipe(Effect.catchTag("MotionCliError", (error) => Effect.gen(function* () {
|
|
23
|
+
yield* Console.error(renderForTerminal(error, verbose));
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return undefined;
|
|
26
|
+
})), Effect.catchIf(CliError.isCliError, (error) => error._tag === "ShowHelp"
|
|
27
|
+
? Effect.succeed(undefined)
|
|
28
|
+
: Effect.gen(function* () {
|
|
29
|
+
yield* Console.error(error.message);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
return undefined;
|
|
32
|
+
})));
|
|
@@ -0,0 +1,9 @@
|
|
|
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>;
|
|
@@ -0,0 +1,94 @@
|
|
|
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"));
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { FileSystem } from "effect/FileSystem";
|
|
2
|
+
import * as Option from "effect/Option";
|
|
3
|
+
import { Path } from "effect/Path";
|
|
4
|
+
import { Command } from "effect/unstable/cli";
|
|
5
|
+
import { MotionCliError } from "../MotionCliError.js";
|
|
6
|
+
export declare const renderCommand: Command.Command<"render", {
|
|
7
|
+
readonly config: Option.Option<string>;
|
|
8
|
+
readonly width: Option.Option<number>;
|
|
9
|
+
readonly height: Option.Option<number>;
|
|
10
|
+
readonly fps: Option.Option<number>;
|
|
11
|
+
readonly dpr: Option.Option<number>;
|
|
12
|
+
readonly seed: Option.Option<string>;
|
|
13
|
+
readonly maxFrames: Option.Option<number>;
|
|
14
|
+
readonly frames: Option.Option<number>;
|
|
15
|
+
readonly outDir: Option.Option<string>;
|
|
16
|
+
readonly format: Option.Option<"mp4">;
|
|
17
|
+
readonly targets: readonly string[];
|
|
18
|
+
}, {}, MotionCliError, import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem | Path>;
|