@effect-motion/cli 0.4.0 → 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.
@@ -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
+ };
@@ -4,13 +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 fps: Option.Option<number>;
9
- readonly dpr: Option.Option<number>;
10
- readonly seed: Option.Option<string>;
11
- readonly maxFrames: Option.Option<number>;
12
- readonly frames: Option.Option<number>;
13
- readonly outDir: Option.Option<string>;
14
- readonly format: Option.Option<"mp4">;
15
- readonly targets: readonly string[];
16
- }, {}, MotionCliError, import("effect/unstable/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem | Path>;
7
+ readonly file: Option.Option<string>;
8
+ }, {}, MotionCliError, FileSystem | Path>;
@@ -1,155 +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
- fps: Flag.optional(Flag.integer("fps").pipe(Flag.withDescription("Frame rate override"))),
17
- dpr: Flag.optional(Flag.float("dpr").pipe(Flag.withDescription("Supersampling factor (output pixels = scene × dpr)"))),
18
- seed: Flag.optional(Flag.string("seed")),
19
- maxFrames: Flag.optional(Flag.integer("max-frames")),
20
- frames: Flag.optional(Flag.integer("frames").pipe(Flag.withDescription("Cap encoded frames (required for infinite scenes)"))),
21
- outDir: Flag.optional(Flag.string("out-dir").pipe(Flag.withDescription("Output directory override"))),
22
- format: Flag.optional(Flag.choice("format", ["mp4"])),
23
- 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"))),
24
23
  };
25
- const overridesFrom = (input) => {
26
- const raw = {
27
- frameRate: opt(input.fps),
28
- dpr: opt(input.dpr),
29
- seed: opt(input.seed),
30
- maxFrames: opt(input.maxFrames),
31
- frames: opt(input.frames),
32
- outDir: opt(input.outDir),
33
- format: opt(input.format),
34
- };
35
- return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined));
36
- };
37
- // a positional is a scene file (configless mode) iff it looks like a module
38
- // path — target names never carry an extension
39
- const isSceneFile = (arg) => /\.(ts|tsx|mts|js|mjs)$/.test(arg);
40
- const sceneBasename = (file) => {
41
- const base = file.split("/").at(-1) ?? file;
42
- return base.replace(/\.(ts|tsx|mts|js|mjs)$/, "");
43
- };
44
- /** Map a resolved target onto the export package's options shape. */
45
- const toVideoOptions = (target) => {
46
- const { dpr, ...settings } = target.settings;
47
- return {
48
- // ponytail: VideoSceneSettings doesn't name seed/backgroundColor, but
49
- // Video.render passes settings straight through to Scene.stream — the
50
- // cast is the CLI-side adaptation decided in design D2; collapse it if
51
- // the export package ever widens its settings type
52
- settings: settings,
53
- ...(dpr !== undefined ? { dpr } : {}),
54
- ...(target.frames !== undefined ? { frames: target.frames } : {}),
55
- };
56
- };
57
- const renderOne = (loader, baseDir, target) => Effect.gen(function* () {
24
+ const handler = (input) => Effect.gen(function* () {
58
25
  const path = yield* Path;
59
26
  const fs = yield* FileSystem;
60
- const sceneAbs = path.isAbsolute(target.scene)
61
- ? target.scene
62
- : path.resolve(baseDir, target.scene);
63
- const module_ = yield* loader.load(sceneAbs);
64
- const scene = module_.scene;
65
- 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)))) {
66
33
  return yield* new MotionCliError({
67
- reason: "SceneLoadFailed",
68
- 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`",
69
38
  });
70
39
  }
71
- const outDirAbs = path.resolve(baseDir, target.outDir);
72
- yield* fs.makeDirectory(outDirAbs, { recursive: true }).pipe(Effect.mapError((cause) => new MotionCliError({
73
- reason: "RenderFailed",
74
- message: `could not create output directory ${outDirAbs}`,
75
- cause,
76
- })));
77
- const outFile = path.join(outDirAbs, target.fileName);
78
- 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({
79
55
  reason: "RenderFailed",
80
- message: `target "${target.name}" failed to render (${sceneAbs})`,
56
+ message: `render entrypoint failed (${entryAbs})`,
81
57
  cause,
82
58
  })));
83
- return outFile;
84
- });
85
- const handler = (input) => Effect.gen(function* () {
86
- const path = yield* Path;
87
- const cwd = process.cwd();
88
- const overrides = overridesFrom(input);
89
- // resolve the target list and the directory paths are relative to
90
- let baseDir;
91
- let resolved;
92
- const [first] = input.targets;
93
- if (first !== undefined &&
94
- input.targets.length === 1 &&
95
- isSceneFile(first)) {
96
- // configless mode: one scene file, library defaults + flags
97
- baseDir = cwd;
98
- resolved = [
99
- resolveTarget({
100
- name: sceneBasename(first),
101
- scene: path.resolve(cwd, first),
102
- output: DEFAULT_OUTPUT_DIR,
103
- }, overrides),
104
- ];
105
- }
106
- else {
107
- const configPath = yield* findConfig(cwd, opt(input.config));
108
- baseDir = path.dirname(configPath);
109
- const loader = yield* makeViteLoader(baseDir);
110
- const config = yield* loadConfig(loader, configPath);
111
- if (input.targets.length === 0) {
112
- resolved = config.targets.map((t) => resolveTarget(t, overrides));
113
- }
114
- else {
115
- const known = new Map(config.targets.map((t) => [t.name, t]));
116
- const unknown = input.targets.filter((name) => !known.has(name));
117
- if (unknown.length > 0) {
118
- return yield* new MotionCliError({
119
- reason: "UnknownTarget",
120
- message: `unknown target${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} — ` +
121
- `known targets: ${[...known.keys()].join(", ") || "(none)"}`,
122
- });
123
- }
124
- resolved = input.targets.map((name) =>
125
- // biome-ignore lint/style/noNonNullAssertion: membership checked above
126
- resolveTarget(known.get(name), overrides));
127
- }
128
- return yield* execute(loader, baseDir, resolved);
129
- }
130
- const loader = yield* makeViteLoader(baseDir);
131
- return yield* execute(loader, baseDir, resolved);
59
+ yield* Console.log(`rendered ${requested}`);
132
60
  }).pipe(Effect.scoped);
133
- // render sequentially: ffmpeg already saturates the CPU per target
134
- // (ponytail: parallelize only if profiling ever says otherwise)
135
- const execute = (loader, baseDir, targets) => Effect.gen(function* () {
136
- const failures = [];
137
- for (const target of targets) {
138
- const result = yield* Effect.result(renderOne(loader, baseDir, target));
139
- if (Result.isSuccess(result)) {
140
- yield* Console.log(`✓ ${target.name} → ${result.success}`);
141
- }
142
- else {
143
- failures.push(result.failure);
144
- yield* Console.error(`✗ ${target.name}: ${result.failure.message}`);
145
- }
146
- }
147
- if (failures.length > 0) {
148
- return yield* new MotionCliError({
149
- reason: "RenderFailed",
150
- message: `${failures.length} of ${targets.length} target${targets.length > 1 ? "s" : ""} failed`,
151
- cause: failures[0],
152
- });
153
- }
154
- });
155
- 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");
@@ -66,35 +96,12 @@ const handler = (input) => Effect.gen(function* () {
66
96
  // fallback is the classic transform, which crashes with
67
97
  // "React is not defined" before anything mounts
68
98
  esbuild: { jsx: "automatic" },
69
- plugins: [
70
- {
71
- name: "motion:project-watch",
72
- configureServer(server) {
73
- // the project lives OUTSIDE the vite root
74
- // (.motion/studio); vite's own add/unlink → glob
75
- // invalidation doesn't reach out-of-root dirs, so
76
- // scene add/remove refreshes the picker here
77
- // (ponytail: invalidateAll + full reload — coarse,
78
- // but studio-sized; narrow it if it ever matters)
79
- const onAddUnlink = (file) => {
80
- if (!file.startsWith(projectRoot))
81
- return;
82
- server.moduleGraph.invalidateAll();
83
- server.ws.send({ type: "full-reload" });
84
- };
85
- server.watcher.on("add", onAddUnlink);
86
- server.watcher.on("unlink", onAddUnlink);
87
- },
88
- },
89
- ],
90
99
  });
91
100
  await server.listen();
92
- // out-of-root project files aren't watched by default — without
93
- // this, editing a scene or the config would not hot reload
94
- server.watcher.add([
95
- `${projectRoot}/src`,
96
- `${projectRoot}/motion.config.ts`,
97
- ]);
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);
98
105
  return server;
99
106
  },
100
107
  catch: (cause) => new MotionCliError({
@@ -108,4 +115,4 @@ const handler = (input) => Effect.gen(function* () {
108
115
  // serve until interrupted (Ctrl-C) — teardown runs via the scope
109
116
  yield* Effect.never;
110
117
  }).pipe(Effect.scoped);
111
- 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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-motion/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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",
@@ -40,8 +40,8 @@
40
40
  "@effect/platform-node": "4.0.0-beta.98",
41
41
  "effect": "4.0.0-beta.98",
42
42
  "vite": "^7.0.0",
43
- "@effect-motion/export": "^0.4.0",
44
- "effect-motion": "^0.4.0"
43
+ "@effect-motion/export": "^0.5.0",
44
+ "effect-motion": "^0.5.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^26.1.1",
@@ -51,7 +51,7 @@
51
51
  "react-dom": "^19.2.0",
52
52
  "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.10",
54
- "@effect-motion/react": "^0.4.0"
54
+ "@effect-motion/react": "^0.5.0"
55
55
  },
56
56
  "scripts": {
57
57
  "build": "tsc -p tsconfig.build.json",
@@ -1,162 +1,68 @@
1
1
  import { Player, type PlayerProps } from "@effect-motion/react";
2
- import { useEffect, useMemo, useState } from "react";
3
- import { projectRoot } from "./project";
2
+ import { useMemo, useState } from "react";
3
+ // generated by `motion studio`: statically re-exports the user's studio.ts,
4
+ // which places the entire scene graph in Vite's module graph (ordinary HMR
5
+ // covers scene edits/additions/removals — no bespoke watchers)
6
+ import studioModule, { studioPath } from "./entry";
7
+ // the CLI's own contract module, copied beside the app at prepare time —
8
+ // one brand, one entry-normalization implementation, shared with `motion`
9
+ import {
10
+ type ResolvedEntry,
11
+ resolveEntries,
12
+ type StudioConfig,
13
+ StudioConfigTypeId,
14
+ } from "./StudioConfig";
4
15
 
5
- // minimal local mirror of the CLI's config types — the app deliberately
6
- // avoids importing @effect-motion/cli so it has zero resolution assumptions
7
- // beyond the project's own dependencies
8
- type TargetLike = {
9
- readonly name?: string;
10
- readonly scene?: string;
11
- readonly settings?: Record<string, unknown>;
12
- readonly player?: Record<string, unknown>;
13
- };
14
- type ConfigLike = { readonly targets?: ReadonlyArray<TargetLike> };
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
-
27
- type SceneEntry = {
28
- /** picker identity: `target:<name>` for config targets, `file:<path>` otherwise */
29
- readonly key: string;
30
- readonly label: string;
31
- /** project-relative module path */
32
- readonly path: string;
33
- readonly registered: boolean;
34
- readonly settings: PlayerProps["settings"] | undefined;
35
- /** studio preview options from the target's `player` block */
36
- readonly player: PlayerOptions | undefined;
37
- readonly load: () => Promise<unknown>;
38
- };
39
-
40
- // every scene in src/scenes is previewable without registration; vite keeps
41
- // this list current as files appear and disappear
42
- const globbed = import.meta.glob("../../src/scenes/*.ts");
43
-
44
- const normalize = (p: string) => p.replace(/^\.\//, "").replace(/^\//, "");
45
- const globKey = (p: string) => normalize(p.replace(/^(\.\.\/)+/, ""));
46
- const fileLabel = (key: string) =>
47
- key
48
- .split("/")
49
- .at(-1)
50
- ?.replace(/\.(ts|tsx|mts|js|mjs)$/, "") ?? key;
16
+ // selection survives the full-page reloads that scene edits trigger
17
+ const selectionFromHash = () =>
18
+ decodeURIComponent(window.location.hash.slice(1)) || null;
51
19
 
52
- const loadByAbsolutePath = (key: string) => () =>
53
- import(/* @vite-ignore */ `/@fs${projectRoot}/${key}`);
20
+ type Resolved =
21
+ | { readonly _tag: "ready"; readonly entries: ReadonlyArray<ResolvedEntry> }
22
+ | { readonly _tag: "error"; readonly message: string };
54
23
 
55
- const buildEntries = (config: ConfigLike | null): ReadonlyArray<SceneEntry> => {
56
- // one entry per config target (a scene file can back several targets with
57
- // different settings), plus one per scenes-dir file no target references
58
- const globLoaders = new Map(
59
- Object.entries(globbed).map(([path, load]) => [globKey(path), load]),
60
- );
61
- const referenced = new Set<string>();
62
- const entries: Array<SceneEntry> = [];
63
- for (const target of config?.targets ?? []) {
64
- if (typeof target?.scene !== "string" || typeof target.name !== "string")
65
- continue;
66
- const path = normalize(target.scene);
67
- referenced.add(path);
68
- entries.push({
69
- key: `target:${target.name}`,
70
- label: target.name,
71
- path,
72
- registered: true,
73
- // a registered scene previews with its target settings so the
74
- // preview aspect matches the export
75
- settings: target.settings as PlayerProps["settings"],
76
- player: target.player as PlayerOptions | undefined,
77
- load: globLoaders.get(path) ?? loadByAbsolutePath(path),
78
- });
79
- }
80
- for (const [path, load] of globLoaders) {
81
- if (referenced.has(path)) continue;
82
- entries.push({
83
- key: `file:${path}`,
84
- label: fileLabel(path),
85
- path,
86
- registered: false,
87
- settings: undefined,
88
- player: undefined,
89
- load,
90
- });
24
+ const resolve = (): Resolved => {
25
+ try {
26
+ return {
27
+ _tag: "ready",
28
+ entries: resolveEntries(studioModule, studioPath),
29
+ };
30
+ } catch (error) {
31
+ return {
32
+ _tag: "error",
33
+ message: error instanceof Error ? error.message : String(error),
34
+ };
91
35
  }
92
- return entries.sort((a, b) => a.label.localeCompare(b.label));
93
36
  };
94
37
 
95
- // selection survives the full-page reloads that scene edits trigger
96
- const selectionFromHash = () =>
97
- decodeURIComponent(window.location.hash.slice(1)) || null;
98
-
99
- type SceneState =
100
- | { readonly _tag: "idle" }
101
- | { readonly _tag: "loading" }
102
- | { readonly _tag: "error"; readonly key: string; readonly message: string }
103
- | {
104
- readonly _tag: "ready";
105
- readonly key: string;
106
- readonly scene: PlayerProps["scene"];
107
- };
38
+ // the config's layers cover the union of every registered scene's
39
+ // resources (typed in the user's studio.ts); the dynamic boundary here is
40
+ // backed by that authoring-time check
41
+ const configLayers =
42
+ typeof studioModule === "object" &&
43
+ studioModule !== null &&
44
+ StudioConfigTypeId in studioModule
45
+ ? (studioModule as StudioConfig).layers
46
+ : undefined;
108
47
 
109
48
  export const App = () => {
110
- const [config, setConfig] = useState<ConfigLike | null>(null);
111
- const [configError, setConfigError] = useState<string | null>(null);
112
49
  const [selected, setSelected] = useState<string | null>(selectionFromHash);
113
- const [state, setState] = useState<SceneState>({ _tag: "idle" });
50
+ const resolved = useMemo(resolve, []);
114
51
 
115
- useEffect(() => {
116
- // the config is optional for studio — a project without one still
117
- // previews everything in src/scenes
118
- import(/* @vite-ignore */ `/@fs${projectRoot}/motion.config.ts`).then(
119
- (module_) => setConfig((module_.default ?? null) as ConfigLike | null),
120
- () => setConfig(null),
52
+ if (resolved._tag === "error") {
53
+ return (
54
+ <main className="studio-main">
55
+ <div className="studio-error">
56
+ <h2>Studio entrypoint failed to load</h2>
57
+ <pre>{resolved.message}</pre>
58
+ </div>
59
+ </main>
121
60
  );
122
- }, []);
61
+ }
123
62
 
124
- const entries = useMemo(() => buildEntries(config), [config]);
63
+ const entries = resolved.entries;
125
64
  const entry = entries.find((e) => e.key === selected) ?? entries[0];
126
65
 
127
- useEffect(() => {
128
- if (entry === undefined) return;
129
- let cancelled = false;
130
- setState({ _tag: "loading" });
131
- setConfigError(null);
132
- entry.load().then(
133
- (module_) => {
134
- if (cancelled) return;
135
- const scene = (module_ as { scene?: PlayerProps["scene"] }).scene;
136
- if (scene === undefined) {
137
- setState({
138
- _tag: "error",
139
- key: entry.key,
140
- message: `${entry.path} has no \`scene\` export`,
141
- });
142
- } else {
143
- setState({ _tag: "ready", key: entry.key, scene });
144
- }
145
- },
146
- (error) => {
147
- if (cancelled) return;
148
- setState({
149
- _tag: "error",
150
- key: entry.key,
151
- message: `failed to load ${entry.path}\n\n${error instanceof Error ? error.message : String(error)}`,
152
- });
153
- },
154
- );
155
- return () => {
156
- cancelled = true;
157
- };
158
- }, [entry]);
159
-
160
66
  return (
161
67
  <>
162
68
  <nav className="studio-sidebar">
@@ -173,51 +79,40 @@ export const App = () => {
173
79
  }}
174
80
  >
175
81
  {e.label}
176
- <small>{e.registered ? e.path : `${e.path} (unregistered)`}</small>
82
+ {e.label !== e.key && <small>{e.key}</small>}
177
83
  </button>
178
84
  ))}
179
- {entries.length === 0 && (
180
- <p className="studio-empty">
181
- No scenes found — add one in src/scenes/
182
- </p>
183
- )}
184
- {configError !== null && <p className="studio-empty">{configError}</p>}
185
85
  </nav>
186
86
  <main className="studio-main">
187
- {state._tag === "error" && (
188
- <div className="studio-error">
189
- <h2>Scene failed to load</h2>
190
- <pre>{state.message}</pre>
191
- </div>
192
- )}
193
- {state._tag === "ready" &&
87
+ {entry !== undefined &&
194
88
  (() => {
195
- // player.fps is a preview override of the scene's own rate,
89
+ // options.fps is a preview override of the scene's own rate,
196
90
  // so it lands in settings.frameRate (the Player's one clock,
197
91
  // which wins over its fps prop); everything else is a plain
198
92
  // Player prop and wins over the studio defaults
199
- const { fps, ...playerProps } = entry?.player ?? {};
93
+ const {
94
+ fps,
95
+ settings: entrySettings,
96
+ ...playerProps
97
+ } = entry.options;
200
98
  const settings =
201
99
  fps !== undefined
202
- ? { ...entry?.settings, frameRate: fps }
203
- : entry?.settings;
100
+ ? { ...entrySettings, frameRate: fps }
101
+ : entrySettings;
204
102
  return (
205
103
  <Player
206
- key={state.key}
207
- scene={state.scene}
104
+ key={entry.key}
105
+ scene={entry.scene as PlayerProps["scene"] as never}
208
106
  autoPlay
209
107
  defaultRepeatMode
210
108
  {...(settings !== undefined ? { settings } : {})}
109
+ {...(configLayers !== undefined
110
+ ? { renderLayers: configLayers as never }
111
+ : {})}
211
112
  {...playerProps}
212
113
  />
213
114
  );
214
115
  })()}
215
- {(state._tag === "idle" || state._tag === "loading") &&
216
- entries.length === 0 && (
217
- <p className="studio-empty">
218
- Create src/scenes/my-scene.ts exporting a `scene` to get started.
219
- </p>
220
- )}
221
116
  </main>
222
117
  </>
223
118
  );
@@ -0,0 +1,5 @@
1
+ // In-repo typecheck stub: re-exports the CLI's contract module. At prepare
2
+ // time this file is NOT copied — the built dist/StudioConfig.js is placed
3
+ // next to the app instead, so the app and CLI share one implementation
4
+ // resolved against the user's project node_modules.
5
+ export * from "../src/StudioConfig.js";
@@ -0,0 +1,4 @@
1
+ // OVERWRITTEN by `motion studio` at prepare time with a static /@fs import
2
+ // of the user's studio entrypoint — this stub only types the app in-repo.
3
+ export const studioPath = "";
4
+ export default undefined as unknown;
package/dist/Config.d.ts DELETED
@@ -1,97 +0,0 @@
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 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
- */
14
- export interface TargetSettings {
15
- readonly frameRate?: number;
16
- readonly seed?: Runner.Seed;
17
- readonly maxFrames?: number;
18
- readonly dpr?: number;
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
- }
42
- /** v1 ships MP4 only; the field exists so more containers slot in later. */
43
- export type OutputFormat = "mp4";
44
- export interface MotionTarget {
45
- /** Unique per config — doubles as the output file basename. */
46
- readonly name: string;
47
- /** Path to a module exporting `scene`, relative to the config file. */
48
- readonly scene: string;
49
- readonly settings?: TargetSettings;
50
- /** Studio-only: how this target previews (autoplay, repeat, …). */
51
- readonly player?: PlayerOptions;
52
- /** Output DIRECTORY (never a file), relative to the config file. */
53
- readonly output?: string;
54
- readonly format?: OutputFormat;
55
- /** Frame cap — required in practice for an infinite scene. */
56
- readonly frames?: number;
57
- }
58
- export interface MotionConfig {
59
- readonly targets: ReadonlyArray<MotionTarget>;
60
- }
61
- /** Identity helper that types `motion.config.ts` (vite/vitest convention). */
62
- export declare const defineConfig: (config: MotionConfig) => MotionConfig;
63
- export declare const DEFAULT_OUTPUT_DIR = "./output";
64
- export declare const DEFAULT_FORMAT: OutputFormat;
65
- /**
66
- * Validate the default export of a loaded config module. Plain structural
67
- * checks (not Schema): the config is authored in TS against `MotionConfig`,
68
- * so this only guards the untyped escape hatches (JS configs, `as any`).
69
- */
70
- export declare const validateConfig: (value: unknown, configPath: string) => MotionConfig;
71
- /** Flag values a `motion render` invocation can lay over a target. */
72
- export interface RenderOverrides {
73
- readonly frameRate?: number;
74
- readonly dpr?: number;
75
- readonly seed?: Runner.Seed;
76
- readonly maxFrames?: number;
77
- readonly frames?: number;
78
- readonly outDir?: string;
79
- readonly format?: OutputFormat;
80
- }
81
- /** A target with overrides applied and output location derived. */
82
- export interface ResolvedTarget {
83
- readonly name: string;
84
- readonly scene: string;
85
- readonly settings: TargetSettings;
86
- readonly frames: number | undefined;
87
- /** Output directory (still config-relative — the caller resolves). */
88
- readonly outDir: string;
89
- /** Derived file name: `<name>.<format>` — never user-specified. */
90
- readonly fileName: string;
91
- }
92
- /**
93
- * Precedence, highest wins: CLI flags → target config → library defaults.
94
- * Library defaults are NOT materialized here — leaving fields undefined
95
- * lets the Runner's own defaults apply, so there is one source of truth.
96
- */
97
- export declare const resolveTarget: (target: MotionTarget, overrides?: RenderOverrides) => ResolvedTarget;
package/dist/Config.js DELETED
@@ -1,67 +0,0 @@
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
- };
@@ -1,15 +0,0 @@
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>;
@@ -1,58 +0,0 @@
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
- });
@@ -1,3 +0,0 @@
1
- // placeholder for typechecking — `motion studio` overwrites this file with
2
- // the absolute project root when it copies the app into .motion/studio
3
- export const projectRoot = "";