@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.
- package/dist/StudioConfig.d.ts +71 -0
- package/dist/StudioConfig.js +76 -0
- package/dist/commands/render.d.ts +2 -10
- package/dist/commands/render.js +44 -138
- package/dist/commands/studio.d.ts +1 -1
- package/dist/commands/studio.js +52 -45
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -4
- package/package.json +4 -4
- package/studio-app/App.tsx +65 -170
- package/studio-app/StudioConfig.ts +5 -0
- package/studio-app/entry.ts +4 -0
- package/dist/Config.d.ts +0 -97
- package/dist/Config.js +0 -67
- package/dist/ConfigLoader.d.ts +0 -15
- package/dist/ConfigLoader.js +0 -58
- package/studio-app/project.ts +0 -3
|
@@ -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
|
|
8
|
-
|
|
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>;
|
package/dist/commands/render.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
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)))) {
|
|
66
33
|
return yield* new MotionCliError({
|
|
67
|
-
reason: "
|
|
68
|
-
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`",
|
|
69
38
|
});
|
|
70
39
|
}
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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: `
|
|
56
|
+
message: `render entrypoint failed (${entryAbs})`,
|
|
81
57
|
cause,
|
|
82
58
|
})));
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
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");
|
|
@@ -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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
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",
|
|
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";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-motion/cli",
|
|
3
|
-
"version": "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.
|
|
44
|
-
"effect-motion": "^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.
|
|
54
|
+
"@effect-motion/react": "^0.5.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"build": "tsc -p tsconfig.build.json",
|
package/studio-app/App.tsx
CHANGED
|
@@ -1,162 +1,68 @@
|
|
|
1
1
|
import { Player, type PlayerProps } from "@effect-motion/react";
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
53
|
-
|
|
20
|
+
type Resolved =
|
|
21
|
+
| { readonly _tag: "ready"; readonly entries: ReadonlyArray<ResolvedEntry> }
|
|
22
|
+
| { readonly _tag: "error"; readonly message: string };
|
|
54
23
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
50
|
+
const resolved = useMemo(resolve, []);
|
|
114
51
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
//
|
|
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 {
|
|
93
|
+
const {
|
|
94
|
+
fps,
|
|
95
|
+
settings: entrySettings,
|
|
96
|
+
...playerProps
|
|
97
|
+
} = entry.options;
|
|
200
98
|
const settings =
|
|
201
99
|
fps !== undefined
|
|
202
|
-
? { ...
|
|
203
|
-
:
|
|
100
|
+
? { ...entrySettings, frameRate: fps }
|
|
101
|
+
: entrySettings;
|
|
204
102
|
return (
|
|
205
103
|
<Player
|
|
206
|
-
key={
|
|
207
|
-
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";
|
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
|
-
};
|
package/dist/ConfigLoader.d.ts
DELETED
|
@@ -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>;
|
package/dist/ConfigLoader.js
DELETED
|
@@ -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
|
-
});
|
package/studio-app/project.ts
DELETED