@effect-motion/cli 0.3.2 → 0.5.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 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): scaffold a project, preview scenes in the browser, render videos — all driven by one `motion.config.ts`.
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
- npx @effect-motion/cli init # scaffold a project
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
- ## 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.
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
 
@@ -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" | "ScaffoldTargetNotEmpty" | "ScaffoldFailed" | "InstallFailed" | "RenderFailed" | "StudioFailed";
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>;
@@ -0,0 +1,71 @@
1
+ import type { PlayerProps } from "@effect-motion/react";
2
+ import type * as Layer from "effect/Layer";
3
+ import * as Scene from "effect-motion/Scene";
4
+ /**
5
+ * The `studio.ts` contract. This module is intentionally browser-safe (the
6
+ * studio app imports the user's entrypoint — and this module's helpers —
7
+ * directly), so it must stay free of Node-only imports.
8
+ *
9
+ * A studio entrypoint default-exports `studioConfig({ scenes, layers })`:
10
+ * a RECORD of scenes (keys are the unique picker identifiers) and ONE
11
+ * `layers` covering the union of every registered scene's resource
12
+ * requirements. The record is the registration — there is no glob; every
13
+ * scene lives in this file's import graph, which is what makes plain Vite
14
+ * HMR cover scene add/edit/remove.
15
+ */
16
+ export declare const StudioConfigTypeId: "~effect-motion/cli/StudioConfig";
17
+ /**
18
+ * The `PlayerProps` preview subset a studio entry may set — typed against
19
+ * the REAL props (no hand-copied mirror): drift in the Player's API is a
20
+ * compile error here, not a silent mismatch.
21
+ */
22
+ export type PlayerOptions = Pick<PlayerProps, "fps" | "autoPlay" | "defaultRepeatMode" | "isInfinite" | "prebufferedFrames" | "bufferCapacity" | "settings">;
23
+ /** A registration: a bare scene, or a scene with per-entry player options. */
24
+ export type StudioEntry = Scene.AnyScene | ({
25
+ readonly scene: Scene.AnyScene;
26
+ } & PlayerOptions);
27
+ type EntryScene<V> = V extends {
28
+ readonly scene: infer S;
29
+ } ? S : V;
30
+ /**
31
+ * The union of loader requirements across every registered scene —
32
+ * `Scene.Resources` distributes over the record's value union, so one
33
+ * `layers` field covers the whole studio (preload-all-provided,
34
+ * studio-wide: switching scenes never waits on a fetch).
35
+ */
36
+ export type EntriesResources<Entries extends Record<string, StudioEntry>> = Scene.Resources<Extract<EntryScene<Entries[keyof Entries]>, Scene.AnyScene>>;
37
+ export interface StudioConfig {
38
+ readonly [StudioConfigTypeId]: typeof StudioConfigTypeId;
39
+ readonly scenes: Record<string, StudioEntry>;
40
+ readonly layers?: Layer.Layer<never, unknown, never>;
41
+ }
42
+ /**
43
+ * Identity helper that types (and brands) a `studio.ts` default export.
44
+ * `layers` is REQUIRED when any registered scene carries loader
45
+ * requirements — a registered scene whose loader is missing from `layers`
46
+ * fails compilation naming the loader — and FORBIDDEN when none do.
47
+ */
48
+ export declare const studioConfig: <const Entries extends Record<string, StudioEntry>>(config: {
49
+ readonly scenes: Entries;
50
+ } & ([EntriesResources<Entries>] extends [never] ? {
51
+ readonly layers?: never;
52
+ } : {
53
+ readonly layers: Layer.Layer<EntriesResources<Entries>, unknown, never>;
54
+ })) => StudioConfig;
55
+ /** A normalized entry, ready for the studio picker. */
56
+ export interface ResolvedEntry {
57
+ /** the record key — the entry's unique identity */
58
+ readonly key: string;
59
+ /** picker label: the scene's display name, else the key */
60
+ readonly label: string;
61
+ readonly scene: Scene.AnyScene;
62
+ readonly options: PlayerOptions;
63
+ }
64
+ export declare const isStudioConfig: (value: unknown) => value is StudioConfig;
65
+ /**
66
+ * Validate a loaded entrypoint's default export and normalize its entries.
67
+ * Guards the untyped escape hatches (JS entrypoints, `as any`) — errors
68
+ * name the file and the offending key.
69
+ */
70
+ export declare const resolveEntries: (value: unknown, filePath: string) => ReadonlyArray<ResolvedEntry>;
71
+ export {};
@@ -0,0 +1,76 @@
1
+ import * as Scene from "effect-motion/Scene";
2
+ import { MotionCliError } from "./MotionCliError.js";
3
+ /**
4
+ * The `studio.ts` contract. This module is intentionally browser-safe (the
5
+ * studio app imports the user's entrypoint — and this module's helpers —
6
+ * directly), so it must stay free of Node-only imports.
7
+ *
8
+ * A studio entrypoint default-exports `studioConfig({ scenes, layers })`:
9
+ * a RECORD of scenes (keys are the unique picker identifiers) and ONE
10
+ * `layers` covering the union of every registered scene's resource
11
+ * requirements. The record is the registration — there is no glob; every
12
+ * scene lives in this file's import graph, which is what makes plain Vite
13
+ * HMR cover scene add/edit/remove.
14
+ */
15
+ export const StudioConfigTypeId = "~effect-motion/cli/StudioConfig";
16
+ /**
17
+ * Identity helper that types (and brands) a `studio.ts` default export.
18
+ * `layers` is REQUIRED when any registered scene carries loader
19
+ * requirements — a registered scene whose loader is missing from `layers`
20
+ * fails compilation naming the loader — and FORBIDDEN when none do.
21
+ */
22
+ export const studioConfig = (config) => ({
23
+ [StudioConfigTypeId]: StudioConfigTypeId,
24
+ ...config,
25
+ });
26
+ const isScene = (value) => typeof value === "object" && value !== null && Scene.TypeId in value;
27
+ export const isStudioConfig = (value) => typeof value === "object" && value !== null && StudioConfigTypeId in value;
28
+ /**
29
+ * Validate a loaded entrypoint's default export and normalize its entries.
30
+ * Guards the untyped escape hatches (JS entrypoints, `as any`) — errors
31
+ * name the file and the offending key.
32
+ */
33
+ export const resolveEntries = (value, filePath) => {
34
+ const fail = (problem) => {
35
+ throw new MotionCliError({
36
+ reason: "ConfigInvalid",
37
+ message: `${filePath}: ${problem}`,
38
+ });
39
+ };
40
+ if (!isStudioConfig(value)) {
41
+ return fail("default export is not a studio config (did you forget `export default studioConfig({ scenes: { ... } })`?)");
42
+ }
43
+ if (typeof value.scenes !== "object" || value.scenes === null) {
44
+ return fail("studio config has no `scenes` record");
45
+ }
46
+ const entries = [];
47
+ for (const [key, entry] of Object.entries(value.scenes)) {
48
+ if (isScene(entry)) {
49
+ entries.push({
50
+ key,
51
+ label: entry.name ?? key,
52
+ scene: entry,
53
+ options: {},
54
+ });
55
+ continue;
56
+ }
57
+ if (typeof entry === "object" &&
58
+ entry !== null &&
59
+ "scene" in entry &&
60
+ isScene(entry.scene)) {
61
+ const { scene, ...options } = entry;
62
+ entries.push({
63
+ key,
64
+ label: scene.name ?? key,
65
+ scene,
66
+ options: options,
67
+ });
68
+ continue;
69
+ }
70
+ return fail(`scenes["${key}"] is neither a scene nor a \`{ scene, ...playerOptions }\` entry`);
71
+ }
72
+ if (entries.length === 0) {
73
+ return fail("studio config registers no scenes");
74
+ }
75
+ return entries;
76
+ };
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: "0.1.0";
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, preview scenes, render videos"), Command.withSubcommands([initCommand, studioCommand, renderCommand]), Command.withGlobalFlags([verboseFlag]));
15
- export const CLI_VERSION = PINS["@effect-motion/cli"];
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
@@ -4,15 +4,5 @@ import { Path } from "effect/Path";
4
4
  import { Command } from "effect/unstable/cli";
5
5
  import { MotionCliError } from "../MotionCliError.js";
6
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>;
7
+ readonly file: Option.Option<string>;
8
+ }, {}, MotionCliError, FileSystem | Path>;
@@ -1,159 +1,61 @@
1
- import { Video } from "@effect-motion/export";
2
1
  import * as Console from "effect/Console";
3
2
  import * as Effect from "effect/Effect";
4
3
  import { FileSystem } from "effect/FileSystem";
5
4
  import * as Option from "effect/Option";
6
5
  import { Path } from "effect/Path";
7
- import * as Result from "effect/Result";
8
- import { Argument, Command, Flag } from "effect/unstable/cli";
9
- import { DEFAULT_OUTPUT_DIR, resolveTarget, } from "../Config.js";
10
- import { findConfig, loadConfig } from "../ConfigLoader.js";
6
+ import { Argument, Command } from "effect/unstable/cli";
11
7
  import { MotionCliError } from "../MotionCliError.js";
12
8
  import { makeViteLoader } from "../ViteLoader.js";
13
- const opt = (o) => Option.getOrUndefined(o);
14
- const renderFlags = {
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
- fps: Flag.optional(Flag.integer("fps").pipe(Flag.withDescription("Frame rate override"))),
19
- dpr: Flag.optional(Flag.float("dpr").pipe(Flag.withDescription("Supersampling factor (output pixels = scene × dpr)"))),
20
- seed: Flag.optional(Flag.string("seed")),
21
- maxFrames: Flag.optional(Flag.integer("max-frames")),
22
- frames: Flag.optional(Flag.integer("frames").pipe(Flag.withDescription("Cap encoded frames (required for infinite scenes)"))),
23
- outDir: Flag.optional(Flag.string("out-dir").pipe(Flag.withDescription("Output directory override"))),
24
- format: Flag.optional(Flag.choice("format", ["mp4"])),
25
- targets: Argument.string("targets").pipe(Argument.withDescription("Target names from the config, or one scene file path"), Argument.variadic()),
9
+ /**
10
+ * `motion render [file]` — execute a render entrypoint.
11
+ *
12
+ * The entrypoint is an ordinary program: it calls `Video.render(scene, out,
13
+ * options)` and provides the scene's loader layers itself, so loader
14
+ * coverage is a compile-time property of the USER's file (`Video.render`'s
15
+ * own signature demands it). The CLI's job is thin: load the module through
16
+ * the shared Vite pipeline, run its default-exported Effect against the
17
+ * platform services bin.ts provides (ChildProcessSpawner included), and
18
+ * render failures as CLI errors. The same file runs without the CLI by
19
+ * self-providing `NodeServices` (documented in `@effect-motion/export`).
20
+ */
21
+ const renderArgs = {
22
+ file: Argument.optional(Argument.string("file").pipe(Argument.withDescription("Render entrypoint (default ./render.ts) — a module default-exporting an Effect"))),
26
23
  };
27
- const overridesFrom = (input) => {
28
- const raw = {
29
- width: opt(input.width),
30
- height: opt(input.height),
31
- frameRate: opt(input.fps),
32
- dpr: opt(input.dpr),
33
- seed: opt(input.seed),
34
- maxFrames: opt(input.maxFrames),
35
- frames: opt(input.frames),
36
- outDir: opt(input.outDir),
37
- format: opt(input.format),
38
- };
39
- return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined));
40
- };
41
- // a positional is a scene file (configless mode) iff it looks like a module
42
- // path — target names never carry an extension
43
- const isSceneFile = (arg) => /\.(ts|tsx|mts|js|mjs)$/.test(arg);
44
- const sceneBasename = (file) => {
45
- const base = file.split("/").at(-1) ?? file;
46
- return base.replace(/\.(ts|tsx|mts|js|mjs)$/, "");
47
- };
48
- /** Map a resolved target onto the export package's options shape. */
49
- const toVideoOptions = (target) => {
50
- const { dpr, ...settings } = target.settings;
51
- return {
52
- // ponytail: VideoSceneSettings doesn't name seed/backgroundColor, but
53
- // Video.render passes settings straight through to Scene.stream — the
54
- // cast is the CLI-side adaptation decided in design D2; collapse it if
55
- // the export package ever widens its settings type
56
- settings: settings,
57
- ...(dpr !== undefined ? { dpr } : {}),
58
- ...(target.frames !== undefined ? { frames: target.frames } : {}),
59
- };
60
- };
61
- const renderOne = (loader, baseDir, target) => Effect.gen(function* () {
24
+ const handler = (input) => Effect.gen(function* () {
62
25
  const path = yield* Path;
63
26
  const fs = yield* FileSystem;
64
- const sceneAbs = path.isAbsolute(target.scene)
65
- ? target.scene
66
- : path.resolve(baseDir, target.scene);
67
- const module_ = yield* loader.load(sceneAbs);
68
- const scene = module_.scene;
69
- if (scene === undefined) {
27
+ const cwd = process.cwd();
28
+ const requested = Option.getOrElse(input.file, () => "./render.ts");
29
+ const entryAbs = path.isAbsolute(requested)
30
+ ? requested
31
+ : path.resolve(cwd, requested);
32
+ if (!(yield* Effect.orDie(fs.exists(entryAbs)))) {
70
33
  return yield* new MotionCliError({
71
- reason: "SceneLoadFailed",
72
- message: `${sceneAbs} has no \`scene\` export`,
34
+ reason: "ConfigNotFound",
35
+ message: `no render entrypoint at ${requested} — create a render.ts that ` +
36
+ "default-exports a `Video.render(...)` effect (loader layers provided), " +
37
+ "or pass a path: `motion render ./my.render.ts`",
73
38
  });
74
39
  }
75
- const outDirAbs = path.resolve(baseDir, target.outDir);
76
- yield* fs.makeDirectory(outDirAbs, { recursive: true }).pipe(Effect.mapError((cause) => new MotionCliError({
77
- reason: "RenderFailed",
78
- message: `could not create output directory ${outDirAbs}`,
79
- cause,
80
- })));
81
- const outFile = path.join(outDirAbs, target.fileName);
82
- yield* Video.render(scene, outFile, toVideoOptions(target)).pipe(Effect.mapError((cause) => new MotionCliError({
40
+ const loader = yield* makeViteLoader(path.dirname(entryAbs));
41
+ const module_ = yield* loader.load(entryAbs);
42
+ const program = module_.default;
43
+ if (!Effect.isEffect(program)) {
44
+ return yield* new MotionCliError({
45
+ reason: "ConfigInvalid",
46
+ message: `${entryAbs}: default export is not an Effect — a render entrypoint ` +
47
+ 'default-exports its render program (e.g. `export default Video.render(scene, "output/out.mp4").pipe(Effect.provide(layers))`)',
48
+ });
49
+ }
50
+ // run against the handler's own context (bin.ts provides the Node
51
+ // platform services). An effect requiring anything beyond them fails
52
+ // here with Effect's named missing-service defect — the loader half of
53
+ // the contract was already enforced in the user's file at compile time.
54
+ yield* program.pipe(Effect.mapError((cause) => new MotionCliError({
83
55
  reason: "RenderFailed",
84
- message: `target "${target.name}" failed to render (${sceneAbs})`,
56
+ message: `render entrypoint failed (${entryAbs})`,
85
57
  cause,
86
58
  })));
87
- return outFile;
88
- });
89
- const handler = (input) => Effect.gen(function* () {
90
- const path = yield* Path;
91
- const cwd = process.cwd();
92
- const overrides = overridesFrom(input);
93
- // resolve the target list and the directory paths are relative to
94
- let baseDir;
95
- let resolved;
96
- const [first] = input.targets;
97
- if (first !== undefined &&
98
- input.targets.length === 1 &&
99
- isSceneFile(first)) {
100
- // configless mode: one scene file, library defaults + flags
101
- baseDir = cwd;
102
- resolved = [
103
- resolveTarget({
104
- name: sceneBasename(first),
105
- scene: path.resolve(cwd, first),
106
- output: DEFAULT_OUTPUT_DIR,
107
- }, overrides),
108
- ];
109
- }
110
- else {
111
- const configPath = yield* findConfig(cwd, opt(input.config));
112
- baseDir = path.dirname(configPath);
113
- const loader = yield* makeViteLoader(baseDir);
114
- const config = yield* loadConfig(loader, configPath);
115
- if (input.targets.length === 0) {
116
- resolved = config.targets.map((t) => resolveTarget(t, overrides));
117
- }
118
- else {
119
- const known = new Map(config.targets.map((t) => [t.name, t]));
120
- const unknown = input.targets.filter((name) => !known.has(name));
121
- if (unknown.length > 0) {
122
- return yield* new MotionCliError({
123
- reason: "UnknownTarget",
124
- message: `unknown target${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} — ` +
125
- `known targets: ${[...known.keys()].join(", ") || "(none)"}`,
126
- });
127
- }
128
- resolved = input.targets.map((name) =>
129
- // biome-ignore lint/style/noNonNullAssertion: membership checked above
130
- resolveTarget(known.get(name), overrides));
131
- }
132
- return yield* execute(loader, baseDir, resolved);
133
- }
134
- const loader = yield* makeViteLoader(baseDir);
135
- return yield* execute(loader, baseDir, resolved);
59
+ yield* Console.log(`rendered ${requested}`);
136
60
  }).pipe(Effect.scoped);
137
- // render sequentially: ffmpeg already saturates the CPU per target
138
- // (ponytail: parallelize only if profiling ever says otherwise)
139
- const execute = (loader, baseDir, targets) => Effect.gen(function* () {
140
- const failures = [];
141
- for (const target of targets) {
142
- const result = yield* Effect.result(renderOne(loader, baseDir, target));
143
- if (Result.isSuccess(result)) {
144
- yield* Console.log(`✓ ${target.name} → ${result.success}`);
145
- }
146
- else {
147
- failures.push(result.failure);
148
- yield* Console.error(`✗ ${target.name}: ${result.failure.message}`);
149
- }
150
- }
151
- if (failures.length > 0) {
152
- return yield* new MotionCliError({
153
- reason: "RenderFailed",
154
- message: `${failures.length} of ${targets.length} target${targets.length > 1 ? "s" : ""} failed`,
155
- cause: failures[0],
156
- });
157
- }
158
- });
159
- export const renderCommand = Command.make("render", renderFlags, handler).pipe(Command.withDescription("Render targets from motion.config.ts (or one scene file) to video"));
61
+ export const renderCommand = Command.make("render", renderArgs, handler).pipe(Command.withDescription("Execute a render entrypoint (default ./render.ts) with the platform provided"));
@@ -4,7 +4,7 @@ import { Path } from "effect/Path";
4
4
  import { Command } from "effect/unstable/cli";
5
5
  import { MotionCliError } from "../MotionCliError.js";
6
6
  export declare const studioCommand: Command.Command<"studio", {
7
- readonly config: Option.Option<string>;
7
+ readonly file: Option.Option<string>;
8
8
  readonly port: Option.Option<number>;
9
9
  readonly host: Option.Option<string>;
10
10
  }, {}, MotionCliError, FileSystem | Path>;
@@ -3,31 +3,52 @@ import * as Effect from "effect/Effect";
3
3
  import { FileSystem } from "effect/FileSystem";
4
4
  import * as Option from "effect/Option";
5
5
  import { Path } from "effect/Path";
6
- import * as Result from "effect/Result";
7
- import { Command, Flag } from "effect/unstable/cli";
8
- import { findConfig } from "../ConfigLoader.js";
6
+ import { Argument, Command, Flag } from "effect/unstable/cli";
9
7
  import { MotionCliError } from "../MotionCliError.js";
10
- const studioFlags = {
11
- config: Flag.optional(Flag.string("config")),
8
+ const studioArgs = {
9
+ file: Argument.optional(Argument.string("file").pipe(Argument.withDescription("Studio entrypoint (default ./studio.ts) — a module default-exporting studioConfig({ scenes, layers })"))),
12
10
  port: Flag.optional(Flag.integer("port")),
13
11
  host: Flag.optional(Flag.string("host")),
14
12
  };
15
13
  /** Shipped studio app source (dist/commands/studio.js → ../../studio-app). */
16
14
  const studioAppSource = (path) => path.join(path.dirname(new URL(import.meta.url).pathname), "..", "..", "studio-app");
15
+ /** Built CLI modules the app imports at runtime (dist/commands → dist). */
16
+ const distSource = (path) => path.join(path.dirname(new URL(import.meta.url).pathname), "..");
17
17
  // The studio app is copied INTO the project (.motion/studio) so its bare
18
- // imports (react, @effect-motion/react, the config's own imports) resolve
19
- // against the project's node_modules — the same graph render uses. The
20
- // generated project.ts pins the project root for /@fs imports.
21
- const prepareStudioDir = (projectRoot) => Effect.gen(function* () {
18
+ // imports (react, @effect-motion/react, the entrypoint's own imports)
19
+ // resolve against the project's node_modules — the same graph render uses.
20
+ // The StudioConfig contract module (and its error dependency) are copied
21
+ // from the CLI's own build so the app and the CLI share ONE brand and one
22
+ // entry-normalization implementation. The generated entry.ts STATICALLY
23
+ // imports the user's entrypoint through /@fs, which places the entire
24
+ // scene graph inside Vite's module graph — ordinary HMR then covers scene
25
+ // edits, additions, and removals with no bespoke watchers.
26
+ const prepareStudioDir = (projectRoot, entryAbs) => Effect.gen(function* () {
22
27
  const fs = yield* FileSystem;
23
28
  const path = yield* Path;
24
29
  const source = studioAppSource(path);
30
+ const dist = distSource(path);
25
31
  const studioDir = path.join(projectRoot, ".motion", "studio");
26
32
  yield* fs.makeDirectory(studioDir, { recursive: true });
27
33
  for (const entry of yield* fs.readDirectory(source)) {
34
+ // the in-repo StudioConfig.ts is a typecheck stub re-exporting the
35
+ // CLI source; the built dist module replaces it below (a copied
36
+ // stub would shadow the .js in vite's resolution)
37
+ if (entry === "StudioConfig.ts") {
38
+ continue;
39
+ }
28
40
  yield* fs.copyFile(path.join(source, entry), path.join(studioDir, entry));
29
41
  }
30
- yield* fs.writeFileString(path.join(studioDir, "project.ts"), `// generated by \`motion studio\` — do not edit\nexport const projectRoot = ${JSON.stringify(projectRoot)};\n`);
42
+ for (const module of ["StudioConfig.js", "MotionCliError.js"]) {
43
+ yield* fs.copyFile(path.join(dist, module), path.join(studioDir, module));
44
+ }
45
+ yield* fs.writeFileString(path.join(studioDir, "entry.ts"), [
46
+ "// generated by `motion studio` — do not edit",
47
+ `import config from "/@fs${entryAbs}";`,
48
+ `export const studioPath = ${JSON.stringify(entryAbs)};`,
49
+ "export default config;",
50
+ "",
51
+ ].join("\n"));
31
52
  return studioDir;
32
53
  }).pipe(Effect.mapError((cause) => cause instanceof MotionCliError
33
54
  ? cause
@@ -38,14 +59,23 @@ const prepareStudioDir = (projectRoot) => Effect.gen(function* () {
38
59
  })));
39
60
  const handler = (input) => Effect.gen(function* () {
40
61
  const path = yield* Path;
62
+ const fs = yield* FileSystem;
41
63
  const cwd = process.cwd();
42
- // a config is optional for studio: unregistered scenes are previewable,
43
- // so "no config" just means "project root = cwd"
44
- const found = yield* Effect.result(findConfig(cwd, Option.getOrUndefined(input.config)));
45
- const projectRoot = Result.isSuccess(found)
46
- ? path.dirname(found.success)
47
- : cwd;
48
- const studioDir = yield* prepareStudioDir(projectRoot);
64
+ // no discovery walk: the entrypoint is an explicit (or default) path
65
+ const requested = Option.getOrElse(input.file, () => "./studio.ts");
66
+ const entryAbs = path.isAbsolute(requested)
67
+ ? requested
68
+ : path.resolve(cwd, requested);
69
+ if (!(yield* Effect.orDie(fs.exists(entryAbs)))) {
70
+ return yield* new MotionCliError({
71
+ reason: "ConfigNotFound",
72
+ message: `no studio entrypoint at ${requested} — create a studio.ts that ` +
73
+ "default-exports `studioConfig({ scenes: { ... } })`, or pass a path: " +
74
+ "`motion studio ./my.studio.ts`",
75
+ });
76
+ }
77
+ const projectRoot = path.dirname(entryAbs);
78
+ const studioDir = yield* prepareStudioDir(projectRoot, entryAbs);
49
79
  const server = yield* Effect.acquireRelease(Effect.tryPromise({
50
80
  try: async () => {
51
81
  const { createServer } = await import("vite");
@@ -61,35 +91,17 @@ const handler = (input) => Effect.gen(function* () {
61
91
  resolve: {
62
92
  dedupe: ["react", "react-dom", "effect", "effect-motion"],
63
93
  },
64
- plugins: [
65
- {
66
- name: "motion:project-watch",
67
- configureServer(server) {
68
- // the project lives OUTSIDE the vite root
69
- // (.motion/studio); vite's own add/unlink → glob
70
- // invalidation doesn't reach out-of-root dirs, so
71
- // scene add/remove refreshes the picker here
72
- // (ponytail: invalidateAll + full reload — coarse,
73
- // but studio-sized; narrow it if it ever matters)
74
- const onAddUnlink = (file) => {
75
- if (!file.startsWith(projectRoot))
76
- return;
77
- server.moduleGraph.invalidateAll();
78
- server.ws.send({ type: "full-reload" });
79
- };
80
- server.watcher.on("add", onAddUnlink);
81
- server.watcher.on("unlink", onAddUnlink);
82
- },
83
- },
84
- ],
94
+ // the studio app's JSX must not depend on the project's
95
+ // tsconfig — scaffolded projects set no `jsx`, and esbuild's
96
+ // fallback is the classic transform, which crashes with
97
+ // "React is not defined" before anything mounts
98
+ esbuild: { jsx: "automatic" },
85
99
  });
86
100
  await server.listen();
87
- // out-of-root project files aren't watched by default — without
88
- // this, editing a scene or the config would not hot reload
89
- server.watcher.add([
90
- `${projectRoot}/src`,
91
- `${projectRoot}/motion.config.ts`,
92
- ]);
101
+ // the project lives OUTSIDE the vite root (.motion/studio);
102
+ // watch it so edits to graph modules out of root hot-reload
103
+ // (vite's default ignores cover node_modules/.git)
104
+ server.watcher.add(projectRoot);
93
105
  return server;
94
106
  },
95
107
  catch: (cause) => new MotionCliError({
@@ -103,4 +115,4 @@ const handler = (input) => Effect.gen(function* () {
103
115
  // serve until interrupted (Ctrl-C) — teardown runs via the scope
104
116
  yield* Effect.never;
105
117
  }).pipe(Effect.scoped);
106
- export const studioCommand = Command.make("studio", studioFlags, handler).pipe(Command.withDescription("Preview scenes in the browser with hot reload (Player + scene picker)"));
118
+ export const studioCommand = Command.make("studio", studioArgs, handler).pipe(Command.withDescription("Preview the scenes registered in a studio.ts entrypoint (Player + picker)"));
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { DEFAULT_FORMAT, DEFAULT_OUTPUT_DIR, defineConfig, type MotionConfig, type MotionTarget, type OutputFormat, type RenderOverrides, type ResolvedTarget, resolveTarget, type TargetSettings, validateConfig, } from "./Config.js";
2
1
  export { MotionCliError, type MotionCliReason } from "./MotionCliError.js";
2
+ export { type EntriesResources, isStudioConfig, type PlayerOptions, type ResolvedEntry, resolveEntries, type StudioConfig, StudioConfigTypeId, type StudioEntry, studioConfig, } from "./StudioConfig.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- // Public API: the config contract (imported by user motion.config.ts files
2
- // and the studio app, so this entry must stay browser-safe) and the error
3
- // type. The commands live behind the `motion` bin, not this entry.
4
- export { DEFAULT_FORMAT, DEFAULT_OUTPUT_DIR, defineConfig, resolveTarget, validateConfig, } from "./Config.js";
1
+ // Public API: the studio entrypoint contract (imported by user studio.ts
2
+ // files and the studio app, so this entry must stay browser-safe) and the
3
+ // error type. The commands live behind the `motion` bin, not this entry.
5
4
  export { MotionCliError } from "./MotionCliError.js";
5
+ export { isStudioConfig, resolveEntries, StudioConfigTypeId, studioConfig, } from "./StudioConfig.js";