@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 +3 -21
- package/dist/MotionCliError.d.ts +1 -1
- package/dist/StudioConfig.d.ts +71 -0
- package/dist/StudioConfig.js +76 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -4
- package/dist/commands/render.d.ts +2 -12
- package/dist/commands/render.js +44 -142
- package/dist/commands/studio.d.ts +1 -1
- package/dist/commands/studio.js +57 -45
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -4
- package/package.json +5 -6
- package/studio-app/App.tsx +80 -158
- package/studio-app/StudioConfig.ts +5 -0
- package/studio-app/entry.ts +4 -0
- package/dist/Config.d.ts +0 -78
- package/dist/Config.js +0 -67
- package/dist/ConfigLoader.d.ts +0 -15
- package/dist/ConfigLoader.js +0 -58
- package/dist/commands/init.d.ts +0 -9
- package/dist/commands/init.js +0 -94
- package/dist/pins.d.ts +0 -23
- package/dist/pins.js +0 -23
- package/dist/scaffold.d.ts +0 -20
- package/dist/scaffold.js +0 -86
- package/studio-app/project.ts +0 -3
- package/templates/default/AGENTS.md +0 -59
- package/templates/default/_gitignore +0 -3
- package/templates/default/motion.config.ts +0 -20
- package/templates/default/src/assets/.gitkeep +0 -0
- package/templates/default/src/main.ts +0 -12
- package/templates/default/src/scenes/hello-world.ts +0 -15
- package/templates/default/tsconfig.json +0 -15
package/README.md
CHANGED
|
@@ -1,32 +1,14 @@
|
|
|
1
1
|
# @effect-motion/cli
|
|
2
2
|
|
|
3
|
-
Command line for [effect-motion](https://github.com/julia-script/effect-motion):
|
|
3
|
+
Command line for [effect-motion](https://github.com/julia-script/effect-motion): preview scenes in the browser and render videos — all driven by one `motion.config.ts`.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
|
|
6
|
+
pnpm create effect-motion # scaffold a project (npm/yarn/bun create work too)
|
|
7
7
|
motion studio # preview scenes with hot reload
|
|
8
8
|
motion render # render every target to MP4
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
`motion init [directory]` scaffolds a single-project workspace:
|
|
14
|
-
|
|
15
|
-
```
|
|
16
|
-
my-project/
|
|
17
|
-
├─ src/
|
|
18
|
-
│ ├─ scenes/hello-world.ts # a scene: a module exporting `scene`
|
|
19
|
-
│ ├─ assets/ # static files
|
|
20
|
-
│ └─ main.ts # the movie — an ordinary scene composing the others
|
|
21
|
-
├─ motion.config.ts # render targets
|
|
22
|
-
├─ AGENTS.md # authoring rules for AI coding agents
|
|
23
|
-
├─ package.json # EXACT pins of effect-motion + effect
|
|
24
|
-
└─ tsconfig.json
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
Answering `.` scaffolds into the current directory and names the project after it. The package-manager prompt defaults to whichever manager invoked the CLI; `--pm <pnpm|npm|yarn|bun>` and `--no-install` skip prompts/install.
|
|
28
|
-
|
|
29
|
-
Dependency versions are pinned **exactly** — the `effect` pin is a determinism invariant (upgrading effect can change seeded random sequences), so upgrade both together, deliberately.
|
|
11
|
+
Scaffolding lives in the [`create-effect-motion`](https://www.npmjs.com/package/create-effect-motion) package — this CLI is installed as a devDependency of the projects it creates.
|
|
30
12
|
|
|
31
13
|
## motion.config.ts
|
|
32
14
|
|
package/dist/MotionCliError.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* signature; adding a failure mode is a union-member addition handled
|
|
5
5
|
* exhaustively at exactly one place (the top-level reporter in bin.ts).
|
|
6
6
|
*/
|
|
7
|
-
export type MotionCliReason = "ConfigNotFound" | "ConfigInvalid" | "SceneLoadFailed" | "UnknownTarget" | "
|
|
7
|
+
export type MotionCliReason = "ConfigNotFound" | "ConfigInvalid" | "SceneLoadFailed" | "UnknownTarget" | "RenderFailed" | "StudioFailed";
|
|
8
8
|
declare const MotionCliError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
9
9
|
readonly _tag: "MotionCliError";
|
|
10
10
|
} & Readonly<A>;
|
|
@@ -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:
|
|
5
|
+
export declare const CLI_VERSION: string;
|
|
6
6
|
/**
|
|
7
7
|
* The single exhaustive failure boundary (design D3a): MotionCliError
|
|
8
8
|
* prints its message (cause chain under --verbose) and sets a non-zero
|
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import * as Console from "effect/Console";
|
|
2
3
|
import * as Effect from "effect/Effect";
|
|
3
4
|
import { CliError, Command, Flag, GlobalFlag } from "effect/unstable/cli";
|
|
4
|
-
import { initCommand } from "./commands/init.js";
|
|
5
5
|
import { renderCommand } from "./commands/render.js";
|
|
6
6
|
import { studioCommand } from "./commands/studio.js";
|
|
7
7
|
import { renderForTerminal } from "./MotionCliError.js";
|
|
8
|
-
import { PINS } from "./pins.js";
|
|
9
8
|
// registered globally so `--verbose` parses anywhere on the command line;
|
|
10
9
|
// the reporter reads argv directly because it sits outside handler context
|
|
11
10
|
const verboseFlag = GlobalFlag.setting("verbose")({
|
|
12
11
|
flag: Flag.boolean("verbose").pipe(Flag.withDescription("Print full error cause chains")),
|
|
13
12
|
});
|
|
14
|
-
export const rootCommand = Command.make("motion").pipe(Command.withDescription("effect-motion: scaffold projects
|
|
15
|
-
export const CLI_VERSION =
|
|
13
|
+
export const rootCommand = Command.make("motion").pipe(Command.withDescription("effect-motion: preview scenes and render videos (scaffold new projects with `pnpm create effect-motion`)"), Command.withSubcommands([studioCommand, renderCommand]), Command.withGlobalFlags([verboseFlag]));
|
|
14
|
+
export const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
16
15
|
/**
|
|
17
16
|
* The single exhaustive failure boundary (design D3a): MotionCliError
|
|
18
17
|
* prints its message (cause chain under --verbose) and sets a non-zero
|
|
@@ -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
|
|
8
|
-
|
|
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>;
|
package/dist/commands/render.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
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: "
|
|
72
|
-
message:
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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: `
|
|
56
|
+
message: `render entrypoint failed (${entryAbs})`,
|
|
85
57
|
cause,
|
|
86
58
|
})));
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
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>;
|
package/dist/commands/studio.js
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
|
|
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
|
|
19
|
-
// against the project's node_modules — the same graph render uses.
|
|
20
|
-
//
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
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",
|
|
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
|
|
2
|
-
// and the studio app, so this entry must stay browser-safe) and the
|
|
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";
|