@coppsary/motionly 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -210,7 +210,7 @@ Open Motionly Assistant beside Assets, enter your own provider key, and describe
210
210
 
211
211
  **Model quality matters.** The AI model you choose directly affects composition, timing, and correct preset use. Smaller models may generate valid syntax but weaker visual decisions. Prefer a current, high-capability model with strong code-generation and instruction-following performance.
212
212
 
213
- For coding agents working inside a project, install the skill with `npx motionly skills add` (or `npx motionly init`, which asks which agent). This writes the `SKILL.md` contract plus a full `references/` library into your agent's folder — Codex (`.agents/`), Claude Code (`.claude/`), Gemini CLI (`.gemini/`), opencode (`.opencode/`), or Kiro (`.kiro/`):
213
+ For coding agents working inside a project, install the skill with `npx @coppsary/motionly skills add` (or `npx @coppsary/motionly init`, which asks which agent). This writes the `SKILL.md` contract plus a full `references/` library into your agent's folder — Codex (`.agents/`), Claude Code (`.claude/`), Gemini CLI (`.gemini/`), opencode (`.opencode/`), or Kiro (`.kiro/`):
214
214
 
215
215
  | Path | Purpose |
216
216
  |---|---|
@@ -223,6 +223,7 @@ Point the agent at `AGENTS.md` and the installed `SKILL.md`, then let it load `r
223
223
  Use this short prompt with an LLM or agent working inside the repository:
224
224
 
225
225
  ```text
226
+ /motionly
226
227
  Read AGENTS.md and .agents/skills/write-motionly/SKILL.md first.
227
228
  Inspect my assets, storyboard the animation, then create a valid .motion project.
228
229
  Use only supported Motionly syntax and presets. Keep one focal subject per shot,
@@ -325,7 +326,7 @@ Motionly ships on npm — no clone and no global install. It needs Node.js `20.1
325
326
  ### Create a project
326
327
 
327
328
  ```bash
328
- npx motionly init demo
329
+ npx @coppsary/motionly init demo
329
330
  ```
330
331
 
331
332
  `init` scaffolds the project and asks **which agent you're using**, then installs the Motionly agent skill for just that one (or choose "All supported agents"). Supported agents: **Codex, Claude Code, Gemini CLI, opencode, and Kiro**. The install is not a single file: it writes the `SKILL.md` contract plus a full `references/` library (`motion-dsl`, `svg`, `animation`, `timeline`, and more) with an `llms.txt` discovery index.
@@ -333,9 +334,9 @@ npx motionly init demo
333
334
  Skip the prompt with flags:
334
335
 
335
336
  ```bash
336
- npx motionly init demo --provider opencode # install for one agent, no prompt
337
- npx motionly init demo --all # every supported agent
338
- npx motionly init demo --skip-skills # no agent skills
337
+ npx @coppsary/motionly init demo --provider opencode # install for one agent, no prompt
338
+ npx @coppsary/motionly init demo --all # every supported agent
339
+ npx @coppsary/motionly init demo --skip-skills # no agent skills
339
340
  ```
340
341
 
341
342
  Provider names are `codex`, `claude`, `gemini`, `opencode`, and `kiro`. Use `--scope project` (default) or `--scope global` to install for every project on your machine.
@@ -354,9 +355,9 @@ demo/
354
355
  ### Add skills to an existing project
355
356
 
356
357
  ```bash
357
- npx motionly skills add # pick scope and agents
358
- npx motionly skills add --all --scope project
359
- npx motionly skills add --provider codex --scope global
358
+ npx @coppsary/motionly skills add # pick scope and agents
359
+ npx @coppsary/motionly skills add --all --scope project
360
+ npx @coppsary/motionly skills add --provider codex --scope global
360
361
  ```
361
362
 
362
363
  Re-running is safe: existing skill files are kept, never overwritten.
@@ -365,10 +366,10 @@ Re-running is safe: existing skill files are kept, never overwritten.
365
366
 
366
367
  ```bash
367
368
  cd demo
368
- npx motionly dev
369
+ npx @coppsary/motionly dev
369
370
  ```
370
371
 
371
- Motionly opens `http://localhost:4173/editor`, loads `project.motion`, serves media from `assets/`, and saves editor changes back to the project. Add `--port <n>` to change the port or `--no-open` to skip launching the browser. For the no-setup browser editor, run `npx motionly`.
372
+ Motionly opens `http://localhost:4173/editor`, loads `project.motion`, serves media from `assets/`, and saves editor changes back to the project. Add `--port <n>` to change the port or `--no-open` to skip launching the browser. For the no-setup browser editor, run `npx @coppsary/motionly`.
372
373
 
373
374
  ## Development from source
374
375
 
@@ -0,0 +1,263 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createReadStream, createWriteStream } from 'node:fs';
5
+ import { mkdtemp, rm, stat } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { Transform } from 'node:stream';
9
+ import { pipeline } from 'node:stream/promises';
10
+
11
+ const jobs = new Map();
12
+ const MAX_FRAME_BYTES = 100 * 1024 * 1024;
13
+ const MAX_AUDIO_BYTES = 1024 * 1024 * 1024;
14
+
15
+ /** Handle an export request in the standalone npm CLI server. */
16
+ export async function handleFfmpegExportRequest(request, response) {
17
+ const url = new URL(request.url ?? '/', 'http://motionly.local');
18
+ if (!url.pathname.startsWith('/api/exports')) return false;
19
+
20
+ try {
21
+ await handleExportRequest(request, response, url);
22
+ } catch (error) {
23
+ if (response.headersSent) {
24
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
25
+ } else {
26
+ response.statusCode = 500;
27
+ response.setHeader('content-type', 'text/plain;charset=utf-8');
28
+ response.end(error instanceof Error ? error.message : String(error));
29
+ }
30
+ }
31
+ return true;
32
+ }
33
+
34
+ /** Connect-compatible middleware used by the Vite development and preview servers. */
35
+ export function createFfmpegExportMiddleware() {
36
+ return (request, response, next) => {
37
+ void handleFfmpegExportRequest(request, response).then((handled) => {
38
+ if (!handled) next();
39
+ }, next);
40
+ };
41
+ }
42
+
43
+ async function handleExportRequest(request, response, url) {
44
+ if (request.method === 'POST' && url.pathname === '/api/exports') {
45
+ await createJob(request, response);
46
+ return;
47
+ }
48
+ const match = /^\/api\/exports\/([a-z0-9-]+)(?:\/(frames\/(\d+)|audio|finish))?$/.exec(
49
+ url.pathname
50
+ );
51
+ if (!match) return respond(response, 404, 'Unknown export endpoint');
52
+ const id = match[1];
53
+ const action = match[2];
54
+ if (!id) return respond(response, 404, 'Unknown export job');
55
+ const job = jobs.get(id);
56
+ if (!job) return respond(response, 404, 'Export job not found');
57
+
58
+ if (request.method === 'DELETE' && !action) {
59
+ jobs.delete(id);
60
+ await rm(job.directory, { recursive: true, force: true });
61
+ respond(response, 204);
62
+ return;
63
+ }
64
+
65
+ if (request.method === 'PUT' && action?.startsWith('frames/')) {
66
+ const index = Number(match[3]);
67
+ if (!Number.isInteger(index) || index < 0 || index >= job.totalFrames) {
68
+ return respond(response, 409, `Invalid frame ${index}`);
69
+ }
70
+ if (job.receivedFrames.has(index)) return respond(response, 409, `Duplicate frame ${index}`);
71
+ if (request.headers['content-type'] !== 'image/jpeg') {
72
+ return respond(response, 415, 'Export frames must be JPEG images');
73
+ }
74
+ await writeRequest(
75
+ request,
76
+ join(job.directory, `frame-${String(index).padStart(8, '0')}.jpg`),
77
+ MAX_FRAME_BYTES
78
+ );
79
+ job.receivedFrames.add(index);
80
+ respond(response, 204);
81
+ return;
82
+ }
83
+
84
+ if (request.method === 'PUT' && action === 'audio') {
85
+ if (!job.hasAudio) return respond(response, 409, 'This export was not created with audio');
86
+ await writeRequest(request, join(job.directory, 'audio-input'), MAX_AUDIO_BYTES);
87
+ job.audioReceived = true;
88
+ respond(response, 204);
89
+ return;
90
+ }
91
+
92
+ if (request.method === 'POST' && action === 'finish') {
93
+ if (job.receivedFrames.size !== job.totalFrames) {
94
+ return respond(
95
+ response,
96
+ 409,
97
+ `Missing ${job.totalFrames - job.receivedFrames.size} export frames`
98
+ );
99
+ }
100
+ if (job.hasAudio && !job.audioReceived) {
101
+ return respond(response, 409, 'The attached audio file was not uploaded');
102
+ }
103
+ await finishJob(id, job, response);
104
+ return;
105
+ }
106
+
107
+ respond(response, 405, 'Method not allowed');
108
+ }
109
+
110
+ async function createJob(request, response) {
111
+ const input = JSON.parse((await readRequest(request, 64 * 1024)).toString('utf8'));
112
+ positiveInteger(input.width, 'width', 8192);
113
+ positiveInteger(input.height, 'height', 8192);
114
+ const fps = positiveNumber(input.fps, 'fps', 240);
115
+ const duration = positiveNumber(input.duration, 'duration', 24 * 60 * 60);
116
+ const totalFrames = positiveInteger(input.totalFrames, 'totalFrames', 24 * 60 * 60 * 240);
117
+ const audioStart = nonNegativeNumber(input.audioStart ?? 0, 'audioStart', duration);
118
+ const expectedFrames = Math.max(1, Math.ceil(duration * fps));
119
+ if (totalFrames !== expectedFrames) throw new Error(`Expected ${expectedFrames} frames`);
120
+ const id = randomUUID();
121
+ const directory = await mkdtemp(join(tmpdir(), 'motionly-export-'));
122
+ jobs.set(id, {
123
+ directory,
124
+ fps,
125
+ duration,
126
+ totalFrames,
127
+ receivedFrames: new Set(),
128
+ hasAudio: input.hasAudio === true,
129
+ audioStart,
130
+ audioReceived: false,
131
+ });
132
+ response.statusCode = 201;
133
+ response.setHeader('content-type', 'application/json');
134
+ response.end(JSON.stringify({ id }));
135
+ }
136
+
137
+ async function finishJob(id, job, response) {
138
+ const outputPath = join(job.directory, 'motionly.mp4');
139
+ const framePattern = join(job.directory, 'frame-%08d.jpg');
140
+ const args = [
141
+ '-hide_banner',
142
+ '-loglevel',
143
+ 'error',
144
+ '-y',
145
+ '-framerate',
146
+ String(job.fps),
147
+ '-start_number',
148
+ '0',
149
+ '-i',
150
+ framePattern,
151
+ ];
152
+ if (job.hasAudio) {
153
+ args.push('-itsoffset', String(job.audioStart), '-i', join(job.directory, 'audio-input'));
154
+ }
155
+ args.push('-map', '0:v:0');
156
+ if (job.hasAudio) args.push('-map', '1:a:0', '-c:a', 'aac', '-b:a', '192k');
157
+ args.push(
158
+ '-c:v',
159
+ 'libx264',
160
+ '-preset',
161
+ 'medium',
162
+ '-crf',
163
+ '18',
164
+ '-vf',
165
+ 'pad=ceil(iw/2)*2:ceil(ih/2)*2:color=black,format=yuv420p',
166
+ '-frames:v',
167
+ String(job.totalFrames),
168
+ '-t',
169
+ String(job.duration),
170
+ '-movflags',
171
+ '+faststart',
172
+ outputPath
173
+ );
174
+
175
+ jobs.delete(id);
176
+ try {
177
+ await runFfmpeg(args);
178
+ const output = await stat(outputPath);
179
+ response.statusCode = 200;
180
+ response.setHeader('content-type', 'video/mp4');
181
+ response.setHeader('content-length', String(output.size));
182
+ await pipeline(createReadStream(outputPath), response);
183
+ } finally {
184
+ await rm(job.directory, { recursive: true, force: true });
185
+ }
186
+ }
187
+
188
+ function runFfmpeg(args) {
189
+ return new Promise((resolve, reject) => {
190
+ const child = spawn('ffmpeg', args, { windowsHide: true });
191
+ let errorOutput = '';
192
+ child.stderr.setEncoding('utf8');
193
+ child.stderr.on('data', (chunk) => {
194
+ errorOutput = `${errorOutput}${chunk}`.slice(-16_384);
195
+ });
196
+ child.once('error', (error) => {
197
+ reject(
198
+ error.code === 'ENOENT'
199
+ ? new Error('ffmpeg is not installed or is not available on PATH')
200
+ : error
201
+ );
202
+ });
203
+ child.once('close', (code) => {
204
+ if (code === 0) resolve();
205
+ else reject(new Error(errorOutput.trim() || `ffmpeg exited with code ${String(code)}`));
206
+ });
207
+ });
208
+ }
209
+
210
+ async function writeRequest(request, path, maxBytes) {
211
+ let size = 0;
212
+ const limiter = new Transform({
213
+ transform(chunk, _encoding, callback) {
214
+ size += chunk.byteLength;
215
+ callback(size > maxBytes ? new Error('Export upload is too large') : null, chunk);
216
+ },
217
+ });
218
+ try {
219
+ await pipeline(request, limiter, createWriteStream(path));
220
+ } catch (error) {
221
+ await rm(path, { force: true });
222
+ throw error;
223
+ }
224
+ }
225
+
226
+ async function readRequest(request, maxBytes) {
227
+ const chunks = [];
228
+ let size = 0;
229
+ for await (const chunk of request) {
230
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
231
+ size += buffer.byteLength;
232
+ if (size > maxBytes) throw new Error('Export upload is too large');
233
+ chunks.push(buffer);
234
+ }
235
+ return Buffer.concat(chunks);
236
+ }
237
+
238
+ function positiveInteger(value, name, max) {
239
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > max) {
240
+ throw new Error(`Invalid export ${name}`);
241
+ }
242
+ return value;
243
+ }
244
+
245
+ function positiveNumber(value, name, max) {
246
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > max) {
247
+ throw new Error(`Invalid export ${name}`);
248
+ }
249
+ return value;
250
+ }
251
+
252
+ function nonNegativeNumber(value, name, max) {
253
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > max) {
254
+ throw new Error(`Invalid export ${name}`);
255
+ }
256
+ return value;
257
+ }
258
+
259
+ function respond(response, status, message) {
260
+ response.statusCode = status;
261
+ if (message) response.setHeader('content-type', 'text/plain;charset=utf-8');
262
+ response.end(message);
263
+ }
package/bin/motionly.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Zero-dependency Motionly CLI and local editor server.
3
3
 
4
4
  import { createServer } from 'node:http';
5
+ import { handleFfmpegExportRequest } from './ffmpeg-export.js';
5
6
  import { createInterface } from 'node:readline/promises';
6
7
  import { spawn } from 'node:child_process';
7
8
  import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
@@ -151,7 +152,11 @@ async function copyReferenceLibrary(source, destination) {
151
152
  const to = join(destination, entry.name);
152
153
  if (entry.isDirectory()) {
153
154
  copied += await copyReferenceLibrary(from, to);
154
- } else if (entry.name === 'SKILL.md' || entry.name === 'llms.txt' || entry.name === 'AGENTS.md') {
155
+ } else if (
156
+ entry.name === 'SKILL.md' ||
157
+ entry.name === 'llms.txt' ||
158
+ entry.name === 'AGENTS.md'
159
+ ) {
155
160
  try {
156
161
  await writeFile(to, await readFile(from), { flag: 'wx' });
157
162
  copied += 1;
@@ -252,7 +257,8 @@ async function promptForAgent(scope) {
252
257
  }
253
258
 
254
259
  async function initProject(name, argv = []) {
255
- if (!name || name.startsWith('-')) throw new Error('Usage: npx motionly init <project-folder>');
260
+ if (!name || name.startsWith('-'))
261
+ throw new Error('Usage: npx @coppsary/motionly init <project-folder>');
256
262
  const target = resolve(name);
257
263
  if (await exists(target)) {
258
264
  if ((await readdir(target)).length) throw new Error(`Folder is not empty: ${target}`);
@@ -286,7 +292,7 @@ async function initProject(name, argv = []) {
286
292
  if (providers.length) await installSkills(skillBase(scope, target), providers);
287
293
  }
288
294
  if (process.stdin.isTTY && process.stdout.isTTY) {
289
- console.log(`\n To reopen later: cd ${name} && npx motionly dev`);
295
+ console.log(`\n To reopen later: cd ${name} && npx @coppsary/motionly dev`);
290
296
  await serveEditor(argv, target);
291
297
  }
292
298
  }
@@ -335,6 +341,8 @@ async function serveEditor(argv, projectFolder = null) {
335
341
  const url = new URL(request.url ?? '/', 'http://localhost');
336
342
  const pathname = decodeURIComponent(url.pathname);
337
343
 
344
+ if (await handleFfmpegExportRequest(request, response)) return;
345
+
338
346
  if (projectPath && pathname === '/api/motion-project') {
339
347
  if (request.method === 'GET' || request.method === 'HEAD') {
340
348
  const source = await readFile(projectPath);
@@ -408,7 +416,7 @@ async function serveEditor(argv, projectFolder = null) {
408
416
  server.on('error', (error) => {
409
417
  if (error.code === 'EADDRINUSE') {
410
418
  console.error(
411
- `Port ${port} is in use. Try: npx motionly ${projectRoot ? 'dev ' : ''}--port ${port + 1}`
419
+ `Port ${port} is in use. Try: npx @coppsary/motionly ${projectRoot ? 'dev ' : ''}--port ${port + 1}`
412
420
  );
413
421
  process.exitCode = 1;
414
422
  return;
@@ -420,14 +428,14 @@ async function serveEditor(argv, projectFolder = null) {
420
428
  function printHelp() {
421
429
  console.log(`Motionly
422
430
 
423
- npx motionly init <project-folder> Create a project; asks which agent to set up
424
- npx motionly init <folder> --provider codex Create a project; install for one agent (no prompt)
425
- npx motionly init <folder> --all Create a project; install for every agent
426
- npx motionly init <folder> --skip-skills Create a project without agent skills
427
- npx motionly skills add Install agent skills into an existing project
428
- npx motionly skills add --all
429
- npx motionly skills add --provider <codex|claude|gemini|opencode|kiro>
430
- npx motionly dev [project-folder] Reopen and edit a local project
431
+ npx @coppsary/motionly init <project-folder> Create a project; asks which agent to set up
432
+ npx @coppsary/motionly init <folder> --provider codex Create a project; install for one agent (no prompt)
433
+ npx @coppsary/motionly init <folder> --all Create a project; install for every agent
434
+ npx @coppsary/motionly init <folder> --skip-skills Create a project without agent skills
435
+ npx @coppsary/motionly skills add Install agent skills into an existing project
436
+ npx @coppsary/motionly skills add --all
437
+ npx @coppsary/motionly skills add --provider <codex|claude|gemini|opencode|kiro>
438
+ npx @coppsary/motionly dev [project-folder] Reopen and edit a local project
431
439
 
432
440
  Options: --scope <project|global>, --port <number>, --no-open`);
433
441
  }
@@ -438,7 +446,7 @@ async function main() {
438
446
  if (command === 'skills') {
439
447
  if (subcommand !== 'add')
440
448
  throw new Error(
441
- 'Usage: npx motionly skills add [--provider <name> | --all] [--scope project|global]'
449
+ 'Usage: npx @coppsary/motionly skills add [--provider <name> | --all] [--scope project|global]'
442
450
  );
443
451
  const options = await resolveSkillOptions(argv.slice(2));
444
452
  await installSkills(skillBase(options.scope, process.cwd()), options.providers);