@impractical-ai/motion 0.1.3 → 0.1.4

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/index.js CHANGED
@@ -1,1505 +1,47 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- ApiError,
4
- invoke,
5
- invokeAt,
6
- jobStatus,
7
- loadConfig,
8
- requireConfig,
9
- saveConfig,
10
- setup,
11
- studioLink,
12
- waitForJobs,
13
- withMeteredCall
14
- } from "./chunk-G56RK3HD.js";
3
+ PACKAGE_VERSION
4
+ } from "./chunk-EFP4P3WA.js";
15
5
 
16
- // src/index.ts
6
+ // src/cli-args.ts
17
7
  import { parseArgs } from "node:util";
18
-
19
- // src/commands.ts
20
- import { readFileSync as readFileSync2, writeFileSync } from "node:fs";
21
- import { randomBytes } from "node:crypto";
22
-
23
- // src/media.ts
24
- import { readFileSync, statSync } from "node:fs";
25
- import { basename, extname } from "node:path";
26
- var MAX_UPLOAD_BYTES = 3e8;
27
- var MIME_BY_EXT = {
28
- ".png": "image/png",
29
- ".jpg": "image/jpeg",
30
- ".jpeg": "image/jpeg",
31
- ".gif": "image/gif",
32
- ".webp": "image/webp",
33
- ".svg": "image/svg+xml",
34
- ".mp4": "video/mp4",
35
- ".webm": "video/webm",
36
- ".mov": "video/quicktime"
37
- };
38
- function mediaType(filePath) {
39
- const type = MIME_BY_EXT[extname(filePath).toLowerCase()];
40
- if (!type) {
41
- throw new Error(
42
- `Unsupported media type for ${basename(filePath)} \u2014 use PNG, JPEG, GIF, WebP, SVG, MP4, WebM, or MOV`
43
- );
44
- }
45
- return type;
46
- }
47
- async function uploadAsset(input) {
48
- const { projectId, draftId, filePath, log } = input;
49
- const bytes = statSync(filePath).size;
50
- if (bytes > MAX_UPLOAD_BYTES) throw new Error("Media must be 300 MB or smaller");
51
- const contentType = mediaType(filePath);
52
- const fileName = basename(filePath);
53
- const upload2 = await invoke("prepare-upload", {
54
- projectId,
55
- draftId,
56
- fileName,
57
- contentType,
58
- bytes
59
- });
60
- const form = new FormData();
61
- form.append("cacheControl", "3600");
62
- form.append("", new File([new Uint8Array(readFileSync(filePath))], fileName, { type: contentType }));
63
- const response = await fetch(upload2.signedUrl, {
64
- method: "PUT",
65
- headers: { "x-upsert": "false" },
66
- body: form
67
- });
68
- if (!response.ok) {
69
- throw new Error(`Upload failed (${response.status}): ${await response.text()}`);
70
- }
71
- log?.(`uploaded ${fileName} (${(bytes / 1e6).toFixed(1)} MB)`);
72
- const registered = await invoke("register-asset", {
73
- projectId,
74
- draftId: null,
75
- storagePath: upload2.storagePath,
76
- fileName,
77
- contentType,
78
- bytes
79
- });
80
- return registered.assetId;
81
- }
82
-
83
- // ../src/studio/sampleLogos.ts
84
- var SAMPLE_LOGOS = [
85
- {
86
- id: "bolt",
87
- name: "Bolt mark",
88
- svg: `<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
89
- <path d="M66 8 24 68h26l-8 44 46-64H60l6-40z" fill="currentColor"/>
90
- <circle cx="97" cy="25" r="7" fill="currentColor" opacity="0.55"/>
91
- <circle cx="22" cy="95" r="5" fill="currentColor" opacity="0.35"/>
92
- </svg>`
93
- },
94
- {
95
- id: "orbit",
96
- name: "Orbit badge",
97
- svg: `<svg viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
98
- <circle cx="60" cy="60" r="44" stroke="currentColor" stroke-width="6"/>
99
- <ellipse cx="60" cy="60" rx="54" ry="20" stroke="currentColor" stroke-width="4" transform="rotate(-24 60 60)"/>
100
- <circle cx="60" cy="60" r="12" fill="currentColor"/>
101
- <circle cx="105" cy="38" r="6" fill="currentColor"/>
102
- </svg>`
103
- },
104
- {
105
- id: "flux",
106
- name: "Flux wordmark",
107
- svg: `<svg viewBox="0 0 208 80" fill="none" stroke="currentColor" stroke-width="9" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
108
- <path d="M24 58V22h28"/>
109
- <path d="M24 40h20"/>
110
- <path d="M68 22v36h24"/>
111
- <path d="M108 22v22c0 18 28 18 28 0V22"/>
112
- <path d="M152 22l30 36"/>
113
- <path d="M182 22l-30 36"/>
114
- </svg>`
115
- }
116
- ];
117
- function sampleLogo(id) {
118
- return SAMPLE_LOGOS.find((s) => s.id === id);
119
- }
120
-
121
- // ../src/studio/templates.ts
122
- var esc = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
123
- var escTemplate = (s) => s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
124
- var num = (v, fallback) => {
125
- const n = Number(v);
126
- return Number.isFinite(n) && v.trim() !== "" ? n : fallback;
127
- };
128
- var HEADER = `import type { Direction } from '../../src/stage/engine'
129
- `;
130
- var matchCut = {
131
- id: "match-cut",
132
- name: "Zoom match cut",
133
- description: "Extreme close-up on one element, hold a beat, then a decisive zoom-out revealing the full UI.",
134
- params: [
135
- { key: "focal", label: "Focal element", type: "selector", default: "button" },
136
- { key: "startScale", label: "Close-up scale", type: "number", default: "8" },
137
- { key: "hold", label: "Hold (s)", type: "number", default: "0.4" },
138
- { key: "zoom", label: "Zoom-out (s)", type: "number", default: "1.5" },
139
- { key: "settle", label: "Settle (s)", type: "number", default: "1.1" }
140
- ],
141
- generate: (p) => `${HEADER}
142
- // Extreme close-up on ${esc(p.focal)}, hold, then zoom out to the full UI.
143
-
144
- const direction: Direction = ({ tl, camera, hold }) => {
145
- camera.set(camera.framing('${esc(p.focal)}', { scale: ${num(p.startScale, 8)} }))
146
-
147
- tl.addLabel('cut', ${num(p.hold, 0.4)})
148
- camera.move(camera.full(), { duration: ${num(p.zoom, 1.5)}, ease: 'expo.inOut' }, 'cut')
149
- hold(${num(p.settle, 1.1)})
150
- }
151
-
152
- export default direction
153
- `
154
- };
155
- var typeAndSend = {
156
- id: "type-and-send",
157
- name: "Type & send",
158
- description: "Close-up on an input, type a message, optionally post it into the UI, then match-cut out. (The Slack demo recipe.)",
159
- params: [
160
- { key: "input", label: "Input element", type: "selector", default: "textarea" },
161
- { key: "message", label: "Message to type", type: "text", default: "hello from motion studio" },
162
- { key: "cps", label: "Typing speed (chars/s)", type: "number", default: "24" },
163
- {
164
- key: "sent",
165
- label: "Sent-message element",
166
- type: "selector",
167
- default: "",
168
- optional: true,
169
- help: 'A pre-rendered (hidden) element the message "posts" into. Leave empty to skip the send.'
170
- },
171
- {
172
- key: "messages",
173
- label: "Scrollable message list",
174
- type: "selector",
175
- default: "",
176
- optional: true,
177
- help: "Scrolled to the latest messages on load so the send lands in frame."
178
- }
179
- ],
180
- generate: (p) => {
181
- const send = p.sent.trim();
182
- const messages = p.messages.trim();
183
- return `${HEADER}
184
- const MESSAGE = '${esc(p.message)}'
185
-
186
- const direction: Direction = ({
187
- tl,
188
- q,
189
- camera,
190
- typeInto,
191
- measureTypedLine,${send ? "\n textRect," : ""}
192
- hold,
193
- }) => {
194
- const input = q<HTMLTextAreaElement>('${esc(p.input)}')
195
- ${send ? ` const sent = q('${esc(send)}')
196
- sent.textContent = MESSAGE
197
- ${messages ? ` const messages = q('${esc(messages)}')
198
- messages.scrollTop = messages.scrollHeight
199
- ` : ""}` : ""}
200
- const line = measureTypedLine(input, MESSAGE)
201
- camera.set(
202
- camera.framing(${send ? "[line, textRect(sent)]" : "line"}, {
203
- fitWidth: 0.6,
204
- fitHeight: 0.55,
205
- maxScale: 12,
206
- })
207
- )
208
-
209
- tl.addLabel('type', 0.4)
210
- const { duration: typeDuration, caret } = typeInto(
211
- input,
212
- MESSAGE,
213
- { cps: ${num(p.cps, 24)} },
214
- 'type'
215
- )
216
- ${send ? `
217
- tl.addLabel('send', 0.4 + typeDuration + 0.3)
218
- tl.set(input, { value: '' }, 'send')
219
- if (caret) tl.to(caret, { autoAlpha: 0, duration: 0.12, ease: 'none' }, 'send')
220
- tl.fromTo(
221
- sent,
222
- { autoAlpha: 0, y: 8 },
223
- { autoAlpha: 1, y: 0, duration: 0.3, ease: 'power2.out' },
224
- 'send'
225
- )
226
-
227
- tl.addLabel('cut', 0.4 + typeDuration + 0.75)` : `
228
- tl.addLabel('cut', 0.4 + typeDuration + 0.3)
229
- if (caret) tl.to(caret, { autoAlpha: 0, duration: 0.15, ease: 'none' }, 'cut')`}
230
- camera.move(camera.full(), { duration: 1.5, ease: 'expo.inOut' }, 'cut')
231
- hold(1.1)
232
- }
233
-
234
- export default direction
235
- `;
236
- }
237
- };
238
- var textReveal = {
239
- id: "text-reveal",
240
- name: "Text reveal",
241
- description: "SplitText character rise on a close-up headline, then pull back while supporting elements fade in.",
242
- params: [
243
- { key: "headline", label: "Headline element", type: "selector", default: "h1" },
244
- {
245
- key: "supporting",
246
- label: "Supporting elements",
247
- type: "text",
248
- default: "",
249
- optional: true,
250
- help: "Comma-separated selectors that fade in during the pull-back."
251
- },
252
- { key: "stagger", label: "Char stagger (s)", type: "number", default: "0.03" }
253
- ],
254
- generate: (p) => {
255
- const supporting = p.supporting.split(",").map((s) => s.trim()).filter(Boolean);
256
- const supportingArr = supporting.map((s) => `'${esc(s)}'`).join(", ");
257
- return `${HEADER}
258
- const direction: Direction = ({ tl, q, camera, split, hold, gsap }) => {
259
- const headline = q('${esc(p.headline)}')
260
- ${supporting.length ? ` const supporting = [${supportingArr}].map((s) => q(s))
261
- ` : ""}
262
- camera.set(camera.framing(headline, { fitWidth: 0.82 }))
263
- ${supporting.length ? ` gsap.set(supporting, { autoAlpha: 0, y: 16 })
264
- ` : ""}
265
- // Characters rise in from behind a mask.
266
- tl.from(
267
- split(headline, { type: 'chars', mask: 'chars' }).chars,
268
- { yPercent: 115, duration: 0.65, stagger: ${num(p.stagger, 0.03)}, ease: 'expo.out' },
269
- 0.25
270
- )
271
-
272
- tl.addLabel('pullback', '+=0.35')
273
- camera.move(camera.full(), { duration: 1.4, ease: 'expo.inOut' }, 'pullback')
274
- ${supporting.length ? ` tl.to(
275
- supporting,
276
- { autoAlpha: 1, y: 0, duration: 0.5, stagger: 0.12, ease: 'power2.out' },
277
- 'pullback+=0.55'
278
- )
279
- ` : ""} hold(1)
280
- }
281
-
282
- export default direction
283
- `;
284
- }
285
- };
286
- var TRANSITIONS = [
287
- "",
288
- "wipe left",
289
- "wipe right",
290
- "wipe up",
291
- "wipe down",
292
- "pixels left",
293
- "pixels right",
294
- "pixels up",
295
- "pixels down",
296
- "iris"
297
- ];
298
- var transitionOutCode = (value, color) => {
299
- const [style, dir] = value.trim().split(/\s+/);
300
- if (!style) return "";
301
- return `
302
- transition.out('${esc(style)}', { ${dir ? `direction: '${esc(dir)}', ` : ""}color: '${esc(color)}' })
303
- hold(0.2)
304
- `;
305
- };
306
- var REVEAL_FX = [
307
- "rise",
308
- "fall",
309
- "blur",
310
- "wave",
311
- "flip",
312
- "pop",
313
- "slide",
314
- "typewriter",
315
- "scramble",
316
- "wipe",
317
- "bounce",
318
- "elastic",
319
- "arc",
320
- "streak"
321
- ];
322
- var EXIT_FX = [
323
- "rise",
324
- "fall",
325
- "blur",
326
- "fade",
327
- "pop",
328
- "scramble",
329
- "wipe",
330
- "scatter",
331
- "streak"
332
- ];
333
- var FX_EFFECTS = [
334
- "blur",
335
- "glow",
336
- "saturate",
337
- "dim",
338
- "hue",
339
- "smear",
340
- "chroma",
341
- "displace",
342
- "grain",
343
- "bloom",
344
- "threshold"
345
- ];
346
- var MASK_SHAPES = [
347
- "feather",
348
- "wipe",
349
- "iris",
350
- "radial",
351
- "slit",
352
- "barn",
353
- "blinds"
354
- ];
355
- var BLEND_MODES = [
356
- "screen",
357
- "multiply",
358
- "overlay",
359
- "difference",
360
- "exclusion",
361
- "color-dodge",
362
- "soft-light",
363
- "luminosity",
364
- "plus-lighter"
365
- ];
366
- var whipCut = {
367
- id: "whip-cut",
368
- name: "Whip cut",
369
- description: "Two statement frames joined by a whip pan: a fast camera move with a blur spike hiding the cut. The house way to change subject without a transition overlay. Self-contained \u2014 no front-end code needed.",
370
- params: [
371
- { key: "first", label: "First line", type: "text", default: "Before" },
372
- { key: "second", label: "Second line", type: "text", default: "After" },
373
- {
374
- key: "effect",
375
- label: "Cut effect",
376
- type: "select",
377
- default: "blur",
378
- options: ["blur", "smear", "chroma"],
379
- help: "blur is the house default; smear is directional; chroma reads as a glitch hit."
380
- },
381
- {
382
- key: "amount",
383
- label: "Effect amount",
384
- type: "number",
385
- default: "14",
386
- help: "Peak of the spike. Blur is px, chroma is channel offset in px."
387
- },
388
- { key: "background", label: "Background (CSS)", type: "text", default: "#0B0B0F" },
389
- { key: "color", label: "Text color", type: "text", default: "#FDFBF7" },
390
- { key: "accent", label: "Accent color", type: "text", default: "#ED1D24" },
391
- {
392
- key: "transition",
393
- label: "Transition out",
394
- type: "select",
395
- default: "",
396
- options: TRANSITIONS,
397
- optional: true
398
- }
399
- ],
400
- generateApp: (p) => `import React from 'react'
401
-
402
- export function App() {
403
- return (
404
- <div
405
- className="relative h-screen w-screen overflow-hidden font-heading"
406
- style={{ background: '${esc(p.background)}', color: '${esc(p.color)}' }}
407
- >
408
- {/* Two panels side by side in ONE stage; the camera pans between them,
409
- so the whip is a real camera move, not a crossfade. */}
410
- <div data-panel-a className="absolute left-0 top-0 flex h-full w-1/2 items-center justify-center">
411
- <h1 data-line-a className="text-[150px] font-bold tracking-[-0.04em]">
412
- ${esc(p.first)}
413
- </h1>
414
- </div>
415
- <div data-panel-b className="absolute right-0 top-0 flex h-full w-1/2 items-center justify-center">
416
- <h1 data-line-b className="text-[150px] font-bold tracking-[-0.04em]" style={{ color: '${esc(p.accent)}' }}>
417
- ${esc(p.second)}
418
- </h1>
419
- </div>
420
- </div>
421
- )
422
- }
423
- `,
424
- generate: (p) => `${HEADER}
425
- // Whip cut: hold on A, whip to B with a blur spike hiding the move.
426
- //
427
- // The spike is ONE call. Do not hand-write a pair of filter tweens \u2014 they
428
- // drift out of sync and the Tweak panel shows two rows you must keep aligned.
429
-
430
- const direction: Direction = ({ tl, q, camera, text, fx, hold, transition }) => {
431
- camera.set(camera.framing('[data-panel-a]', { fitWidth: 0.92 }))
432
- text.reveal('[data-line-a]', 'rise', { duration: 0.6 }, 0.2)
433
- hold(0.9)
434
-
435
- // \u2014 the whip \u2014
436
- tl.addLabel('whip', '+=0')
437
- camera.move(
438
- camera.framing('[data-panel-b]', { fitWidth: 0.92 }),
439
- { duration: 0.28, ease: 'promo-inout' },
440
- 'whip'
441
- )
442
- fx.spike('[data-panel-a]', '${esc(p.effect)}', { amount: ${num(p.amount, 14)}, duration: 0.3 }, 'whip')
443
- fx.spike('[data-panel-b]', '${esc(p.effect)}', { amount: ${num(p.amount, 14)}, duration: 0.3 }, 'whip')
444
-
445
- text.reveal('[data-line-b]', 'rise', { duration: 0.5 }, 'whip+=0.22')
446
- hold(1.1)
447
- ${transitionOutCode(p.transition ?? "", p.accent.trim() || "#ED1D24")}}
448
-
449
- export default direction
450
- `
451
- };
452
- var softReveal = {
453
- id: "soft-reveal",
454
- name: "Soft reveal",
455
- description: "A statement card revealed through a SOFT-edged moving mask, with an optional effect settling as it lands. Feathered edges are impossible with clip-path \u2014 this is the premium version of a wipe. Self-contained.",
456
- params: [
457
- { key: "headline", label: "Headline", type: "text", default: "Softer than a wipe" },
458
- { key: "subline", label: "Subline", type: "text", default: "", optional: true },
459
- {
460
- key: "shape",
461
- label: "Mask shape",
462
- type: "select",
463
- default: "feather",
464
- options: MASK_SHAPES,
465
- help: "feather and radial are soft-edged; the rest are hard (clip-path)."
466
- },
467
- {
468
- key: "direction",
469
- label: "Travel direction",
470
- type: "select",
471
- default: "right",
472
- options: ["right", "left", "up", "down"]
473
- },
474
- {
475
- key: "softness",
476
- label: "Edge softness",
477
- type: "number",
478
- default: "0.3",
479
- help: "0 is a hard edge, 1 feathers across the whole box."
480
- },
481
- {
482
- key: "settle",
483
- label: "Settle effect",
484
- type: "select",
485
- default: "bloom",
486
- options: ["", ...FX_EFFECTS],
487
- optional: true,
488
- help: "Spikes once as the mask finishes landing."
489
- },
490
- { key: "background", label: "Background (CSS)", type: "text", default: "#12131A" },
491
- { key: "color", label: "Text color", type: "text", default: "#FDFBF7" },
492
- {
493
- key: "transition",
494
- label: "Transition out",
495
- type: "select",
496
- default: "",
497
- options: TRANSITIONS,
498
- optional: true
499
- }
500
- ],
501
- generateApp: (p) => `import React from 'react'
502
-
503
- export function App() {
504
- return (
505
- <div
506
- className="flex h-screen w-screen items-center justify-center overflow-hidden font-heading"
507
- style={{ background: '${esc(p.background)}', color: '${esc(p.color)}' }}
508
- >
509
- <div data-card className="flex flex-col items-center gap-10 px-32 text-center">
510
- <h1 className="text-[140px] font-bold leading-[0.95] tracking-[-0.04em]">
511
- ${esc(p.headline)}
512
- </h1>${p.subline.trim() ? `
513
- <p className="text-3xl font-medium opacity-70">${esc(p.subline.trim())}</p>` : ""}
514
- </div>
515
- </div>
516
- )
517
- }
518
- `,
519
- generate: (p) => `${HEADER}
520
- // Soft reveal: the card arrives through a feathered mask rather than a hard
521
- // clip edge, then the effect settles once on landing.
522
-
523
- const direction: Direction = ({ camera, mask, fx, hold, transition }) => {
524
- camera.set(camera.full())
525
-
526
- mask.reveal(
527
- '[data-card]',
528
- '${esc(p.shape)}',
529
- { direction: '${esc(p.direction)}', softness: ${num(p.softness, 0.3)}, duration: 1.0 },
530
- 0.15
531
- )
532
- ${(p.settle ?? "").trim() ? ` fx.spike('[data-card]', '${esc(p.settle.trim())}', { duration: 0.7 }, 0.85)
533
- ` : ""} hold(1.4)
534
- ${transitionOutCode(p.transition ?? "", "#0B0B0F")}}
535
-
536
- export default direction
537
- `
538
- };
539
- var gradientType = {
540
- id: "gradient-type",
541
- name: "Gradient type",
542
- description: "A headline whose GLYPHS are filled with a live gradient that drifts across them, with an optional blend-mode layer behind. Uses background-clip:text \u2014 the fill lives inside the letterforms, not behind them. Self-contained.",
543
- params: [
544
- { key: "headline", label: "Headline", type: "text", default: "filled type" },
545
- {
546
- key: "gradient",
547
- label: "Fill gradient (CSS)",
548
- type: "text",
549
- default: "linear-gradient(90deg,#ED1D24,#00B8FF,#CDF463,#ED1D24)",
550
- help: "Any CSS gradient. Repeat the first colour last so the drift loops seamlessly."
551
- },
552
- {
553
- key: "drift",
554
- label: "Drift distance",
555
- type: "number",
556
- default: "100",
557
- help: "How far the fill travels across the glyphs, in percent."
558
- },
559
- {
560
- key: "blend",
561
- label: "Backdrop blend",
562
- type: "select",
563
- default: "",
564
- options: ["", ...BLEND_MODES],
565
- optional: true,
566
- help: "Composites a disc behind the type in this mode. screen for light-on-dark."
567
- },
568
- { key: "background", label: "Background (CSS)", type: "text", default: "#0B0B0F" },
569
- {
570
- key: "transition",
571
- label: "Transition out",
572
- type: "select",
573
- default: "",
574
- options: TRANSITIONS,
575
- optional: true
576
- }
577
- ],
578
- generateApp: (p) => `import React from 'react'
579
-
580
- export function App() {
581
- return (
582
- <div
583
- className="relative flex h-screen w-screen items-center justify-center overflow-hidden font-heading"
584
- style={{ background: '${esc(p.background)}' }}
585
- >${(p.blend ?? "").trim() ? `
586
- <div
587
- data-backdrop
588
- className="absolute left-1/2 top-1/2 h-[620px] w-[620px] -translate-x-1/2 -translate-y-1/2 rounded-full"
589
- style={{ background: '#00B8FF' }}
590
- />` : ""}
591
- <h1
592
- data-fill-type
593
- className="relative text-[180px] font-bold leading-[0.95] tracking-[-0.05em]"
594
- >
595
- ${esc(p.headline)}
596
- </h1>
597
- </div>
598
- )
599
- }
600
- `,
601
- generate: (p) => `${HEADER}
602
- // Gradient type: the fill lives INSIDE the letterforms and drifts across
603
- // them. mask.fill sets background-clip:text and drives one custom property,
604
- // so the Tweak panel gets a single "Fill drift" slider.
605
-
606
- const direction: Direction = ({ camera, mask, hold, transition }) => {
607
- camera.set(camera.full())
608
- ${(p.blend ?? "").trim() ? `
609
- // The backdrop composites against this beat only \u2014 mask.blend isolates the
610
- // enclosing block so the mode cannot reach past it.
611
- mask.blend('[data-backdrop]', '${esc(p.blend.trim())}', { duration: 0.6 }, 0.1)
612
- ` : ""}
613
- mask.fill(
614
- '[data-fill-type]',
615
- { gradient: '${esc(p.gradient)}', drift: ${num(p.drift, 100)}, duration: 1.6 },
616
- 0.2
617
- )
618
- hold(1.2)
619
- ${transitionOutCode(p.transition ?? "", "#0B0B0F")}}
620
-
621
- export default direction
622
- `
623
- };
624
- var titleCard = {
625
- id: "title-card",
626
- name: "Title card",
627
- description: "A self-contained text card \u2014 headline, optional subline, background color, and a named text effect. No front-end code needed.",
628
- params: [
629
- { key: "headline", label: "Headline", type: "text", default: "Make it move" },
630
- {
631
- key: "subline",
632
- label: "Subline",
633
- type: "text",
634
- default: "",
635
- optional: true
636
- },
637
- {
638
- key: "effect",
639
- label: "Reveal effect",
640
- type: "select",
641
- default: "rise",
642
- options: REVEAL_FX
643
- },
644
- {
645
- key: "exit",
646
- label: "Exit effect",
647
- type: "select",
648
- default: "fade",
649
- options: ["", ...EXIT_FX],
650
- optional: true,
651
- help: "Empty holds the end frame."
652
- },
653
- { key: "background", label: "Background (CSS)", type: "text", default: "#0B0B0F" },
654
- { key: "color", label: "Text color", type: "text", default: "#FFFFFF" },
655
- {
656
- key: "accent",
657
- label: "Underline accent color",
658
- type: "text",
659
- default: "",
660
- optional: true,
661
- help: "When set, sketches an underline stroke beneath the last headline word."
662
- },
663
- {
664
- key: "transition",
665
- label: "Transition out",
666
- type: "select",
667
- default: "",
668
- options: TRANSITIONS,
669
- optional: true,
670
- help: "Covers the frame with color at the very end \u2014 cut to the next clip from there."
671
- }
672
- ],
673
- generateApp: (p) => {
674
- const words = p.headline.trim().split(/\s+/);
675
- const last = p.accent.trim() ? words.pop() : void 0;
676
- return `import React from 'react'
677
-
678
- export function App() {
679
- return (
680
- <div
681
- className="flex h-screen w-screen flex-col items-center justify-center overflow-hidden font-heading"
682
- style={{ background: '${esc(p.background)}', color: '${esc(p.color)}' }}
683
- >
684
- <h1 data-headline className="text-8xl font-bold tracking-tight">
685
- ${esc(words.join(" "))}${last ? ` <span data-accent-word>${esc(last)}</span>` : ""}
686
- </h1>${p.subline.trim() ? `
687
- <p data-sub className="mt-8 text-2xl font-normal opacity-70">
688
- ${esc(p.subline.trim())}
689
- </p>` : ""}
690
- </div>
691
- )
692
- }
693
- `;
694
- },
695
- generate: (p) => {
696
- const effect = REVEAL_FX.includes(p.effect.trim()) ? p.effect.trim() : "rise";
697
- const exit = EXIT_FX.includes(p.exit.trim()) ? p.exit.trim() : "";
698
- const sub = !!p.subline.trim();
699
- const accent = p.accent.trim();
700
- const transition = transitionOutCode(
701
- p.transition ?? "",
702
- accent || "#CDF463"
703
- );
704
- return `${HEADER}
705
- // Title card: '${esc(effect)}' reveal on the headline, pull back to the full
706
- // frame${sub ? " while the subline rises in" : ""}${exit ? `, then a '${esc(exit)}' exit` : ""}${transition ? ", ending under a full-frame color transition" : ""}.
707
-
708
- const direction: Direction = ({ tl, q, camera, text${transition ? ", transition" : ""}, hold }) => {
709
- const headline = q('[data-headline]')
710
-
711
- camera.set(camera.framing(headline, { fitWidth: 0.75 }))
712
- const reveal = text.reveal(headline, '${esc(effect)}', {}, 0.35)
713
- ${accent ? ` text.underline('[data-accent-word]', { color: '${esc(accent)}' }, 0.35 + reveal.duration * 0.7)
714
- ` : ""}
715
- tl.addLabel('pullback', 0.35 + reveal.duration + 0.35)
716
- camera.move(camera.full(), { duration: 1.2, ease: 'expo.inOut' }, 'pullback')
717
- ${sub ? ` text.reveal('[data-sub]', 'rise', { by: 'words' }, 'pullback+=0.45')
718
- ` : ""} hold(0.9)
719
- ${exit ? `
720
- tl.addLabel('exit')
721
- text.exit(headline, '${esc(exit)}', {}, 'exit')
722
- ${accent ? ` text.exit('svg', 'fade', {}, 'exit')
723
- ` : ""}${sub ? ` text.exit('[data-sub]', '${esc(exit === "fade" ? "fade" : exit)}', {}, 'exit+=0.08')
724
- ` : ""} hold(0.4)
725
- ` : ""}${transition}}
726
-
727
- export default direction
728
- `;
729
- }
730
- };
731
- var LOGO_REVEAL_FX = [
732
- "draw",
733
- "build",
734
- "pop",
735
- "blur",
736
- "rise",
737
- "wipe",
738
- "iris",
739
- "slit",
740
- "spin",
741
- "flip",
742
- "bounce",
743
- "glitch"
744
- ];
745
- var LOGO_SVG_ONLY_FX = ["draw", "build"];
746
- var LOGO_EXIT_FX = ["pop", "blur", "fade", "wipe", "iris", "drop"];
747
- function parseLogoSource(value) {
748
- if (value.startsWith("svg:")) return { kind: "svg", markup: value.slice(4) };
749
- if (value.startsWith("asset:")) return { kind: "asset", path: value.slice(6) };
750
- if (value.startsWith("sample:")) return { kind: "sample", id: value.slice(7) };
751
- return { kind: "sample", id: "bolt" };
752
- }
753
- var logoSting = {
754
- id: "logo-sting",
755
- name: "Logo sting",
756
- description: "Animate any logo \u2014 a bundled sample mark, pasted SVG, or an uploaded SVG/PNG \u2014 with a named reveal, a glint sweep, an optional tagline, and an exit. No front-end code needed.",
757
- params: [
758
- { key: "logo", label: "Logo", type: "logo", default: "sample:bolt" },
759
- { key: "size", label: "Logo width (px)", type: "number", default: "280" },
760
- {
761
- key: "effect",
762
- label: "Reveal effect",
763
- type: "select",
764
- default: "draw",
765
- options: LOGO_REVEAL_FX,
766
- help: "draw and build need an SVG logo (they trace/assemble its shapes)."
767
- },
768
- {
769
- key: "shine",
770
- label: "Glint sweep",
771
- type: "select",
772
- default: "yes",
773
- options: ["yes", "no"]
774
- },
775
- {
776
- key: "tagline",
777
- label: "Tagline",
778
- type: "text",
779
- default: "",
780
- optional: true,
781
- help: "Rises in word by word during the pull-back."
782
- },
783
- {
784
- key: "exit",
785
- label: "Exit effect",
786
- type: "select",
787
- default: "",
788
- options: ["", ...LOGO_EXIT_FX],
789
- optional: true,
790
- help: "Empty holds the end frame."
791
- },
792
- { key: "background", label: "Background (CSS)", type: "text", default: "#0B0B0F" },
793
- { key: "color", label: "Logo/text color", type: "text", default: "#FFFFFF" },
794
- {
795
- key: "transition",
796
- label: "Transition out",
797
- type: "select",
798
- default: "",
799
- options: TRANSITIONS,
800
- optional: true,
801
- help: "Covers the frame with color at the very end \u2014 cut to the next clip from there."
802
- }
803
- ],
804
- generateApp: (p) => {
805
- const source = parseLogoSource(p.logo ?? "");
806
- const size = num(p.size, 280);
807
- const tagline = p.tagline?.trim();
808
- const taglineJsx = tagline ? `
809
- <p
810
- data-tagline
811
- className="text-3xl font-medium tracking-tight opacity-80"
812
- >
813
- ${esc(tagline)}
814
- </p>` : "";
815
- if (source.kind === "asset") {
816
- return `import React from 'react'
817
-
818
- export function App() {
819
- return (
820
- <div
821
- className="flex h-screen w-screen flex-col items-center justify-center gap-12 overflow-hidden font-heading"
822
- style={{ background: '${esc(p.background)}', color: '${esc(p.color)}' }}
823
- >
824
- <div data-logo style={{ width: ${size} }}>
825
- <img src="${esc(source.path)}" alt="" className="w-full" />
826
- </div>${taglineJsx}
827
- </div>
828
- )
829
- }
830
- `;
831
- }
832
- const markup = source.kind === "svg" ? source.markup : (sampleLogo(source.id) ?? SAMPLE_LOGOS[0]).svg;
833
- return `import React from 'react'
834
-
835
- // Inline SVG so draw/build can trace the shapes. The markup was sanitized
836
- // when the clip was created.
837
- const LOGO_SVG = \`${escTemplate(markup)}\`
838
-
839
- export function App() {
840
- return (
841
- <div
842
- className="flex h-screen w-screen flex-col items-center justify-center gap-12 overflow-hidden font-heading"
843
- style={{ background: '${esc(p.background)}', color: '${esc(p.color)}' }}
844
- >
845
- <div
846
- data-logo
847
- style={{ width: ${size} }}
848
- dangerouslySetInnerHTML={{ __html: LOGO_SVG }}
849
- />${taglineJsx}
850
- </div>
851
- )
852
- }
853
- `;
854
- },
855
- generate: (p) => {
856
- const source = parseLogoSource(p.logo ?? "");
857
- let effect = LOGO_REVEAL_FX.includes(p.effect?.trim()) ? p.effect.trim() : "draw";
858
- if (source.kind === "asset" && LOGO_SVG_ONLY_FX.includes(effect)) {
859
- effect = "pop";
860
- }
861
- const shine = p.shine !== "no";
862
- const tagline = !!p.tagline?.trim();
863
- const exit = LOGO_EXIT_FX.includes(p.exit?.trim()) ? p.exit.trim() : "";
864
- const transition = transitionOutCode(p.transition ?? "", "#CDF463");
865
- return `${HEADER}
866
- // Logo sting: '${esc(effect)}' reveal on the mark${shine ? ", a glint sweep" : ""}, pull back${tagline ? " while the tagline rises" : ""}, one pulse${exit ? `, then a '${esc(exit)}' exit` : ""}${transition ? ", ending under a full-frame color transition" : ""}.
867
-
868
- const direction: Direction = ({ tl, camera, logo${tagline ? ", text" : ""}${transition ? ", transition" : ""}, hold }) => {
869
- camera.set(camera.framing('[data-logo]', { fitHeight: 0.5, clamp: true }))
870
-
871
- const reveal = logo.reveal('[data-logo]', '${esc(effect)}', {}, 0.35)
872
- ${shine ? ` logo.shine('[data-logo]', {}, 0.35 + reveal.duration + 0.15)
873
- ` : ""}
874
- tl.addLabel('pullback', 0.35 + reveal.duration + ${shine ? "1.35" : "0.45"})
875
- camera.move(camera.full(), { duration: 1.1, ease: 'expo.inOut' }, 'pullback')
876
- ${tagline ? ` text.reveal('[data-tagline]', 'rise', { by: 'words' }, 'pullback+=0.4')
877
- ` : ""} logo.emphasize('[data-logo]', 'pulse', {}, 'pullback+=1.3')
878
- hold(1.0)
879
- ${exit ? `
880
- tl.addLabel('exit')
881
- logo.exit('[data-logo]', '${esc(exit)}', {}, 'exit')
882
- ${tagline ? ` text.exit('[data-tagline]', 'fade', {}, 'exit')
883
- ` : ""} hold(0.4)
884
- ` : ""}${transition}}
885
-
886
- export default direction
887
- `;
888
- }
889
- };
890
- var launchPromo = {
891
- id: "launch-promo",
892
- name: "Launch promo",
893
- description: "Kinetic-type promo: headline + counting pill badge, dive-through-the-badge transition, accent line stack, motion-blur streak list, pill end card.",
894
- params: [
895
- { key: "headline", label: "Headline", type: "text", default: "Collection" },
896
- { key: "count", label: "Pill counts up to", type: "number", default: "50" },
897
- {
898
- key: "lines",
899
- label: "Stacked lines (comma-separated)",
900
- type: "text",
901
- default: "50 new, Javascript effects, now live",
902
- help: "The last line gets the accent color."
903
- },
904
- {
905
- key: "streaks",
906
- label: "Streak list (comma-separated)",
907
- type: "text",
908
- default: "Tutorials, Code Source, Copy to Webflow, Responsive",
909
- help: "Slides in from alternating sides with motion blur."
910
- },
911
- { key: "url", label: "End-card text", type: "text", default: "yoursite.com" },
912
- { key: "accent", label: "Accent color", type: "text", default: "#CDF463" },
913
- {
914
- key: "transition",
915
- label: "Transition out",
916
- type: "select",
917
- default: "",
918
- options: TRANSITIONS,
919
- optional: true,
920
- help: "Covers the frame with the accent at the very end \u2014 cut to the next clip from there."
921
- }
922
- ],
923
- generateApp: (p) => {
924
- const accent = esc(p.accent.trim() || "#CDF463");
925
- const lines = p.lines.split(",").map((s) => s.trim()).filter(Boolean);
926
- const streaks = p.streaks.split(",").map((s) => s.trim()).filter(Boolean);
927
- return `import React from 'react'
928
-
929
- const ACCENT = '${accent}'
930
-
931
- export function App() {
932
- return (
933
- <div className="relative h-screen w-screen overflow-hidden bg-[#0A0A0A] font-heading text-white">
934
- {/* beat 1 \u2014 headline + counting pill */}
935
- <div data-beat-1 className="absolute inset-0 flex items-center justify-center gap-7">
936
- <h1 data-headline className="text-8xl font-bold tracking-tight">
937
- ${esc(p.headline)}
938
- </h1>
939
- <div
940
- data-pill
941
- className="-mt-14 rounded-full px-7 py-2 text-5xl font-semibold"
942
- style={{ background: ACCENT, color: '#101010' }}
943
- >
944
- <span data-count>${esc(p.count)}</span>
945
- </div>
946
- </div>
947
-
948
- {/* beat 2 \u2014 stacked lines, last one in the accent */}
949
- <div data-beat-2 className="absolute inset-0 flex items-center justify-center">
950
- <div className="text-center text-8xl font-bold leading-[1.06] tracking-tight">
951
- ${lines.map(
952
- (l, i) => ` <div data-line${i === lines.length - 1 ? " style={{ color: ACCENT }}" : ""}>${esc(l)}</div>`
953
- ).join("\n")}
954
- </div>
955
- </div>
956
-
957
- {/* beat 3 \u2014 motion-blur streak list */}
958
- <div data-beat-3 className="absolute inset-0 flex items-center justify-center">
959
- <div data-streaks className="text-center text-7xl font-bold leading-[1.15] tracking-tight text-white/85">
960
- ${streaks.map((l) => ` ${esc(l)}<br />`).join("\n")}
961
- </div>
962
- </div>
963
-
964
- {/* beat 4 \u2014 pill end card */}
965
- <div data-beat-4 className="absolute inset-0 flex items-center justify-center">
966
- <div
967
- data-endpill
968
- className="rounded-full px-16 py-7 text-6xl font-semibold"
969
- style={{ background: ACCENT, color: '#101010' }}
970
- >
971
- ${esc(p.url)}
972
- </div>
973
- </div>
974
-
975
- {/* the accent flood used by the dive-through transition */}
976
- <div data-cover className="absolute inset-0" style={{ background: ACCENT }} />
977
- </div>
978
- )
979
- }
980
- `;
981
- },
982
- generate: (p) => `${HEADER}
983
- // Kinetic launch promo: count up in the pill, dive INTO it (the accent
984
- // floods the frame), iris out onto the stacked reveal, hard-cut to the
985
- // streak list, hard-cut to the pill end card.
986
-
987
- const direction: Direction = ({ tl, q, qa, camera, text${p.transition?.trim() ? ", transition" : ""}, hold, gsap }) => {
988
- const pill = q('[data-pill]')
989
- const cover = q('[data-cover]')
990
- const beats = ['1', '2', '3', '4'].map((n) => q(\`[data-beat-\${n}]\`))
991
-
992
- // Framings are computed up front, before anything moves.
993
- const pillClose = camera.framing(pill, { fitWidth: 0.42 })
994
- const pillDive = camera.framing(pill, { scale: 34 })
995
- camera.set(camera.full())
996
-
997
- gsap.set(beats.slice(1), { autoAlpha: 0 })
998
- gsap.set(cover, { autoAlpha: 0, clipPath: 'circle(75% at 50% 50%)' })
999
-
1000
- // \u2014 beat 1: headline rises, pill pops, camera pushes in while it counts \u2014
1001
- text.reveal('[data-headline]', 'rise', {}, 0.3)
1002
- tl.from(
1003
- pill,
1004
- { scale: 0, autoAlpha: 0, duration: 0.5, ease: 'back.out(2)', transformOrigin: '50% 50%' },
1005
- 0.75
1006
- )
1007
- camera.move(pillClose, { duration: 0.8, ease: 'expo.inOut' }, 1.35)
1008
- text.count('[data-count]', { from: 0, to: ${num(p.count, 50)}, duration: 1.1 }, 1.45)
1009
-
1010
- // \u2014 dive through the pill: accent floods the frame, iris out on beat 2 \u2014
1011
- tl.addLabel('dive', 3.0)
1012
- camera.move(pillDive, { duration: 0.55, ease: 'power3.in' }, 'dive')
1013
- tl.set(cover, { autoAlpha: 1 }, 'dive+=0.53')
1014
- camera.move(camera.full(), { duration: 0.01, ease: 'none' }, 'dive+=0.55')
1015
- tl.set(beats[0], { autoAlpha: 0 }, 'dive+=0.56')
1016
- tl.set(beats[1], { autoAlpha: 1 }, 'dive+=0.56')
1017
- tl.to(
1018
- cover,
1019
- { clipPath: 'circle(0% at 50% 50%)', duration: 0.6, ease: 'power3.inOut' },
1020
- 'dive+=0.7'
1021
- )
1022
-
1023
- // \u2014 beat 2: stacked lines, one masked rise per line \u2014
1024
- tl.addLabel('stack', 'dive+=0.85')
1025
- qa('[data-line]').forEach((line, i) => {
1026
- text.reveal(line, 'rise', { duration: 0.55, stagger: 0.015 }, \`stack+=\${i * 0.22}\`)
1027
- })
1028
- hold(1.0)
1029
-
1030
- // \u2014 beat 3: hard cut to the streak list \u2014
1031
- tl.addLabel('streaks')
1032
- tl.set(beats[1], { autoAlpha: 0 }, 'streaks')
1033
- tl.set(beats[2], { autoAlpha: 1 }, 'streaks')
1034
- text.reveal('[data-streaks]', 'streak', {}, 'streaks+=0.1')
1035
- hold(1.1)
1036
-
1037
- // \u2014 beat 4: hard cut to the pill end card \u2014
1038
- tl.addLabel('end')
1039
- tl.set(beats[2], { autoAlpha: 0 }, 'end')
1040
- tl.set(beats[3], { autoAlpha: 1 }, 'end')
1041
- tl.from(
1042
- '[data-endpill]',
1043
- { scale: 0.2, autoAlpha: 0, duration: 0.6, ease: 'back.out(1.8)', transformOrigin: '50% 50%' },
1044
- 'end+=0.05'
1045
- )
1046
- text.emphasize('[data-endpill]', 'pulse', {}, 'end+=0.9')
1047
- hold(1.0)
1048
- ${transitionOutCode(p.transition ?? "", p.accent.trim() || "#CDF463")}}
1049
-
1050
- export default direction
1051
- `
1052
- };
1053
- var blank = {
1054
- id: "blank",
1055
- name: "Blank",
1056
- description: "A commented skeleton \u2014 write the direction yourself.",
1057
- params: [],
1058
- generate: () => `${HEADER}
1059
- // One paused master timeline drives everything; the exporter renders it
1060
- // frame-by-frame. Toolkit reference: README.md.
1061
-
1062
- const direction: Direction = ({ tl, q, camera, typeInto, split, hold, gsap }) => {
1063
- // Open on a close-up:
1064
- // camera.set(camera.framing('h1', { fitWidth: 0.8 }))
1065
-
1066
- // Text animation (chars/words/lines):
1067
- // tl.from(split('h1', { type: 'chars', mask: 'chars' }).chars,
1068
- // { yPercent: 115, stagger: 0.03, duration: 0.6, ease: 'expo.out' }, 0.3)
1069
-
1070
- // Pull back to the full UI:
1071
- // camera.move(camera.full(), { duration: 1.4, ease: 'expo.inOut' }, '+=0.4')
1072
-
1073
- hold(1)
1074
- }
1075
-
1076
- export default direction
1077
- `
1078
- };
1079
- var TEMPLATES = [
1080
- titleCard,
1081
- logoSting,
1082
- launchPromo,
1083
- matchCut,
1084
- typeAndSend,
1085
- textReveal,
1086
- // fx / mask kit templates. These carry the capabilities an unaided agent
1087
- // cannot reach: a synced whip-cut spike, feathered mask edges, and
1088
- // fill-inside-the-glyphs type.
1089
- whipCut,
1090
- softReveal,
1091
- gradientType,
1092
- blank
1093
- ];
1094
-
1095
- // ../src/studio/parseFiles.ts
1096
- var FENCE = /```([^\n`]*)\n([\s\S]*?)```/g;
1097
- var DROP = /* @__PURE__ */ new Set([
1098
- "index.tsx",
1099
- "index.jsx",
1100
- "main.tsx",
1101
- "index.css",
1102
- "styles.css",
1103
- "package.json",
1104
- "tailwind.config.js",
1105
- "tailwind.config.ts",
1106
- "postcss.config.js",
1107
- "vite.config.ts",
1108
- "tsconfig.json"
1109
- ]);
1110
- var SOURCE_EXT = /\.(tsx|ts|jsx|js)$/;
1111
- function cleanPath(label) {
1112
- const token = label.trim().split(/\s+/).pop() ?? "";
1113
- const p = token.replace(/^\.?\//, "");
1114
- if (!p || p.includes("..") || p.startsWith("/")) return null;
1115
- return p;
1116
- }
1117
- function parseSceneFiles(pasted) {
1118
- const blocks = [];
1119
- for (const m of pasted.matchAll(FENCE)) {
1120
- const p = cleanPath(m[1]);
1121
- if (p && SOURCE_EXT.test(p)) {
1122
- blocks.push({ path: p, content: m[2].replace(/\s+$/, "") + "\n" });
1123
- }
1124
- }
1125
- if (blocks.length === 0) {
1126
- return { "App.tsx": pasted.trim() + "\n" };
1127
- }
1128
- const files = {};
1129
- for (const { path, content } of blocks) {
1130
- if (DROP.has(path)) continue;
1131
- files[path] = content;
1132
- }
1133
- if (!files["App.tsx"]) {
1134
- const keys2 = Object.keys(files);
1135
- if (keys2.length === 1) {
1136
- files["App.tsx"] = files[keys2[0]];
1137
- if (keys2[0] !== "App.tsx") delete files[keys2[0]];
1138
- } else {
1139
- throw new Error(
1140
- "No App.tsx found in the pasted code. Include a fenced ```App.tsx block, or paste a single component."
1141
- );
1142
- }
1143
- }
1144
- return files;
1145
- }
1146
-
1147
- // ../src/shared/initial-files.ts
1148
- var TITLE_TEMPLATE = TEMPLATES.find((t) => t.id === "title-card");
1149
- var BLANK_DIRECTION = TEMPLATES.find((t) => t.id === "blank").generate({});
1150
- var defaultParams = (t) => Object.fromEntries(t.params.map((p) => [p.key, p.default]));
1151
- function buildInitialFiles(input) {
1152
- const selectedTemplate = input.templateId ? TEMPLATES.find((template) => template.id === input.templateId) : TEMPLATES.find((template) => template.id === "blank");
1153
- if (!selectedTemplate) throw new Error(`Unknown motion template: ${input.templateId ?? "blank"}`);
1154
- const selectedParams = {
1155
- ...defaultParams(selectedTemplate),
1156
- ...input.templateParams ?? {}
1157
- };
1158
- const starter = input.appCode?.trim() ? parseSceneFiles(input.appCode) : {
1159
- "App.tsx": (selectedTemplate.generateApp ?? TITLE_TEMPLATE.generateApp)({
1160
- ...selectedTemplate.generateApp ? selectedParams : {
1161
- ...defaultParams(TITLE_TEMPLATE),
1162
- headline: input.valueProp.trim(),
1163
- subline: "Built to move at the speed of your work."
1164
- }
1165
- })
1166
- };
1167
- const projectMarker = JSON.stringify({
1168
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1169
- valueProp: input.valueProp.trim(),
1170
- ...input.brand ? { brand: input.brand.slug } : {}
1171
- }) + "\n";
1172
- const brandFile = input.brand ? JSON.stringify(input.brand, null, 2) + "\n" : null;
1173
- return {
1174
- ...starter,
1175
- "direction.ts": selectedTemplate.id === "blank" ? BLANK_DIRECTION : selectedTemplate.generate(selectedParams),
1176
- "project.json": projectMarker,
1177
- ...brandFile ? { "brand.json": brandFile } : {}
1178
- };
1179
- }
1180
-
1181
- // ../src/shared/video-models.ts
1182
- var DEFAULT_OPENROUTER_VIDEO_MODEL = "anthropic/claude-opus-5";
1183
-
1184
- // src/commands.ts
1185
- var flagString = (flags, name) => {
1186
- const value = flags[name];
1187
- if (Array.isArray(value)) return value[value.length - 1];
1188
- return typeof value === "string" ? value : void 0;
1189
- };
1190
- var flagList = (flags, name) => {
1191
- const value = flags[name];
1192
- if (Array.isArray(value)) return value;
1193
- return typeof value === "string" ? [value] : [];
8
+ var CLI_OPTIONS = {
9
+ key: { type: "string" },
10
+ "supabase-url": { type: "string" },
11
+ "app-url": { type: "string" },
12
+ "value-prop": { type: "string" },
13
+ vibe: { type: "string" },
14
+ directors: { type: "string" },
15
+ "code-file": { type: "string" },
16
+ brand: { type: "string" },
17
+ asset: { type: "string", multiple: true },
18
+ project: { type: "string" },
19
+ feedback: { type: "string" },
20
+ model: { type: "string" },
21
+ slug: { type: "string" },
22
+ name: { type: "string" },
23
+ jobs: { type: "string" },
24
+ dir: { type: "string" },
25
+ out: { type: "string" },
26
+ draft: { type: "string" },
27
+ id: { type: "string" },
28
+ wait: { type: "boolean" },
29
+ agent: { type: "boolean" },
30
+ claude: { type: "boolean" },
31
+ "no-open": { type: "boolean" },
32
+ reextract: { type: "boolean" },
33
+ help: { type: "boolean" },
34
+ version: { type: "boolean", short: "v" }
1194
35
  };
1195
- var flagBool = (flags, name) => flags[name] === true;
1196
- var out = (line) => console.log(line);
1197
- async function setup2(flags) {
1198
- const claude = flagBool(flags, "claude") || flagBool(flags, "agent");
1199
- if (flagBool(flags, "agent")) {
1200
- out("Note: --agent is deprecated; use --claude next time.");
1201
- }
1202
- await setup({
1203
- installAgentSkills: false,
1204
- installClaudePlugin: claude,
1205
- openBrowser: !flagBool(flags, "no-open"),
1206
- log: out
1207
- });
1208
- }
1209
- async function login(flags) {
1210
- const patch = {};
1211
- const key = flagString(flags, "key");
1212
- const supabaseUrl = flagString(flags, "supabase-url");
1213
- const appUrl = flagString(flags, "app-url");
1214
- if (key) patch.apiKey = key;
1215
- if (supabaseUrl) patch.supabaseUrl = supabaseUrl;
1216
- if (appUrl) patch.appUrl = appUrl;
1217
- const config = { ...loadConfig(), ...patch };
1218
- if (!config.apiKey || !config.supabaseUrl) {
1219
- out("Missing API key. Run: npx --yes @impractical-ai/motion setup --claude");
1220
- process.exitCode = 1;
1221
- return;
1222
- }
1223
- const snapshot = await invokeAt(
1224
- config.supabaseUrl,
1225
- "workspace-bootstrap",
1226
- {},
1227
- config.apiKey
1228
- );
1229
- if (Object.keys(patch).length) saveConfig(patch);
1230
- out(`org: ${snapshot.orgId}`);
1231
- out(`plan: ${snapshot.plan} (access: ${snapshot.hasAccess ? "yes" : "no"})`);
1232
- if (snapshot.accessUntil) out(`access until: ${snapshot.accessUntil}`);
1233
- out("Logged in. Credentials stored in ~/.impractical/config.json");
1234
- }
1235
- async function whoami(_flags) {
1236
- const snapshot = await invoke("workspace-bootstrap");
1237
- out(`org: ${snapshot.orgId}`);
1238
- out(`plan: ${snapshot.plan} (access: ${snapshot.hasAccess ? "yes" : "no"})`);
1239
- if (snapshot.accessUntil) out(`access until: ${snapshot.accessUntil}`);
1240
- }
1241
- async function keys(positional, flags) {
1242
- const action = positional[0];
1243
- if (action === "list" || !action) {
1244
- const result = await invoke("create-api-key", {
1245
- action: "list"
1246
- });
1247
- for (const key of result.keys) {
1248
- out(
1249
- `${key.id} ${key.keyPrefix}\u2026 "${key.name}" created ${key.createdAt}` + (key.revokedAt ? " REVOKED" : key.lastUsedAt ? ` last used ${key.lastUsedAt}` : " never used")
1250
- );
1251
- }
1252
- if (!result.keys.length) out("No API keys. Create one from the studio web app.");
1253
- return;
1254
- }
1255
- if (action === "revoke") {
1256
- const id = positional[1] ?? flagString(flags, "id");
1257
- if (!id) throw new Error("Usage: impractical keys revoke <keyId>");
1258
- await invoke("create-api-key", { action: "revoke", id });
1259
- out(`Revoked ${id}`);
1260
- return;
1261
- }
1262
- throw new Error(`Unknown keys action "${action}" (use: list, revoke)`);
1263
- }
1264
- var slugify = (text) => text.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().split(/\s+/).slice(0, 4).join("-") || "video";
1265
- async function generate(flags) {
1266
- const valueProp = flagString(flags, "value-prop");
1267
- if (!valueProp) throw new Error('Usage: impractical generate --value-prop "..." [--wait]');
1268
- const config = requireConfig();
1269
- const appCode = flagString(flags, "code-file") ? readFileSync2(flagString(flags, "code-file"), "utf8") : void 0;
1270
- let brand2 = null;
1271
- const brandSlug = flagString(flags, "brand");
1272
- if (brandSlug) {
1273
- const result = await invoke("brand-kits", { action: "get", slug: brandSlug });
1274
- brand2 = result.brand;
1275
- }
1276
- const projectId = flagString(flags, "project");
1277
- const feedback = flagString(flags, "feedback");
1278
- const append = Boolean(projectId && feedback);
1279
- const slug = flagString(flags, "slug") ?? `${slugify(valueProp)}-${randomBytes(2).toString("hex")}`;
1280
- const directorIds = (flagString(flags, "directors") ?? "d1,d2").split(",").map((d) => d.trim());
1281
- const files = buildInitialFiles({ valueProp, appCode, brand: brand2 });
1282
- const prepared = await invoke("prepare-generation", {
1283
- ...projectId ? { projectId } : {},
1284
- name: (flagString(flags, "name") ?? slug).replace(/[-_]/g, " "),
1285
- slug,
1286
- valueProp: valueProp.trim(),
1287
- vibe: flagString(flags, "vibe") ?? "confident",
1288
- directorIds,
1289
- append,
1290
- files
36
+ function parseCliArgs(args) {
37
+ return parseArgs({
38
+ args,
39
+ options: CLI_OPTIONS,
40
+ allowPositionals: true
1291
41
  });
1292
- out(`project: ${prepared.projectId}`);
1293
- for (const draft of prepared.drafts) out(`draft: ${draft.id} (${draft.slug})`);
1294
- const assetIds = [];
1295
- for (const assetPath of flagList(flags, "asset")) {
1296
- assetIds.push(
1297
- await uploadAsset({
1298
- projectId: prepared.projectId,
1299
- draftId: prepared.drafts[0].id,
1300
- filePath: assetPath,
1301
- log: out
1302
- })
1303
- );
1304
- }
1305
- const started = await invoke(
1306
- "start-generation",
1307
- {
1308
- projectId: prepared.projectId,
1309
- draftIds: prepared.drafts.map((draft) => draft.id),
1310
- assetIds,
1311
- model: flagString(flags, "model") ?? DEFAULT_OPENROUTER_VIDEO_MODEL,
1312
- ...feedback ? { feedback } : {}
1313
- }
1314
- );
1315
- for (const job of started.jobs) out(`job: ${job.jobId} (draft ${job.draftId})`);
1316
- const firstSlug = prepared.drafts[0]?.slug;
1317
- out(`studio: ${studioLink(config.appUrl, prepared.projectId, firstSlug)}`);
1318
- if (!flagBool(flags, "wait")) {
1319
- out("Generation started. Follow with: impractical status --jobs " + started.jobs.map((j) => j.jobId).join(","));
1320
- return;
1321
- }
1322
- const slugByDraft = new Map(prepared.drafts.map((draft) => [draft.id, draft.slug]));
1323
- const seen = /* @__PURE__ */ new Map();
1324
- const rows = await waitForJobs(
1325
- started.jobs.map((job) => job.jobId),
1326
- (latest) => {
1327
- for (const row of latest) {
1328
- const line = `${slugByDraft.get(row.draft_id) ?? row.draft_id}: ${row.status}${row.stage ? ` \u2014 ${row.stage}` : ""}${row.detail ? ` (${row.detail})` : ""}`;
1329
- if (seen.get(row.id) !== line) {
1330
- seen.set(row.id, line);
1331
- out(line);
1332
- }
1333
- }
1334
- }
1335
- );
1336
- reportJobs(rows, slugByDraft);
1337
- out(`studio: ${studioLink(config.appUrl, prepared.projectId, firstSlug)}`);
1338
- if (rows.some((row) => row.status !== "completed")) process.exitCode = 1;
1339
- }
1340
- function reportJobs(rows, slugByDraft) {
1341
- for (const row of rows) {
1342
- const label = slugByDraft?.get(row.draft_id) ?? row.draft_id;
1343
- if (row.status === "completed") {
1344
- out(`${label}: completed (revision ${row.revision ?? "?"})`);
1345
- if (row.master?.url) out(` master: ${row.master.url}`);
1346
- } else {
1347
- out(`${label}: ${row.status}${row.error ? ` \u2014 ${row.error}` : ""}`);
1348
- }
1349
- }
1350
- }
1351
- async function status(flags) {
1352
- const projectId = flagString(flags, "project");
1353
- const jobsFlag = flagString(flags, "jobs");
1354
- if (projectId) {
1355
- const result = await invoke("list-projects", { projectId });
1356
- out(`${result.project.name} (${result.project.id})`);
1357
- for (const draft of result.project.drafts) {
1358
- out(` draft ${draft.id} (${draft.slug}) \u2014 ${draft.status}, revision ${draft.source_revision}`);
1359
- }
1360
- for (const job of result.project.jobs.slice(0, 10)) {
1361
- out(` job ${job.id} [${job.draft_id}] \u2014 ${job.status}${job.stage ? ` (${job.stage})` : ""}${job.error ? ` \u2014 ${job.error}` : ""}`);
1362
- }
1363
- return;
1364
- }
1365
- if (!jobsFlag) throw new Error("Usage: impractical status --project <id> | --jobs <id,id> [--wait]");
1366
- const jobIds = jobsFlag.split(",").map((id) => id.trim());
1367
- if (flagBool(flags, "wait")) {
1368
- const seen = /* @__PURE__ */ new Map();
1369
- const rows = await waitForJobs(jobIds, (latest) => {
1370
- for (const row of latest) {
1371
- const line = `${row.id}: ${row.status}${row.stage ? ` \u2014 ${row.stage}` : ""}`;
1372
- if (seen.get(row.id) !== line) {
1373
- seen.set(row.id, line);
1374
- out(line);
1375
- }
1376
- }
1377
- });
1378
- reportJobs(rows);
1379
- if (rows.some((row) => row.status !== "completed")) process.exitCode = 1;
1380
- return;
1381
- }
1382
- reportJobs(await jobStatus(jobIds));
1383
- }
1384
- async function exportDraft(positional, flags) {
1385
- const draftId = positional[0];
1386
- if (!draftId) {
1387
- throw new Error("Usage: impractical export <draftId> [--wait] [--out video.mp4]");
1388
- }
1389
- const started = await invoke("start-export", { draftId });
1390
- out(`Export started (job ${started.jobId}).`);
1391
- if (!flagBool(flags, "wait") && !flagString(flags, "out")) {
1392
- out(`Follow with: impractical status --jobs ${started.jobId} --wait`);
1393
- return;
1394
- }
1395
- const rows = await waitForJobs([started.jobId], () => {
1396
- }, 36e5);
1397
- const row = rows[0];
1398
- if (row?.status !== "completed" || !row.master?.url) {
1399
- throw new Error(`Export ${row?.status ?? "failed"}${row?.error ? `: ${row.error}` : ""}`);
1400
- }
1401
- out(`master: ${row.master.url}`);
1402
- const outPath = flagString(flags, "out");
1403
- if (outPath) {
1404
- const response = await fetch(row.master.url);
1405
- if (!response.ok) throw new Error(`Download failed (${response.status})`);
1406
- writeFileSync(outPath, new Uint8Array(await response.arrayBuffer()));
1407
- out(`Saved ${(row.master.bytes / 1e6).toFixed(1)} MB to ${outPath}`);
1408
- }
1409
- }
1410
- async function upload(positional, flags) {
1411
- const filePath = positional[0];
1412
- const projectId = flagString(flags, "project");
1413
- const draftId = flagString(flags, "draft");
1414
- if (!filePath || !projectId || !draftId) {
1415
- throw new Error("Usage: impractical upload <file> --project <projectId> --draft <draftId>");
1416
- }
1417
- const assetId = await uploadAsset({ projectId, draftId, filePath, log: out });
1418
- out(`assetId: ${assetId}`);
1419
- }
1420
- async function link(positional, _flags) {
1421
- const config = requireConfig();
1422
- const [projectId, slug] = positional;
1423
- if (!projectId) throw new Error("Usage: impractical link <projectId> [draftSlug]");
1424
- out(studioLink(config.appUrl, projectId, slug));
1425
- }
1426
- async function brand(positional, flags) {
1427
- const action = positional[0];
1428
- if (action === "extract") {
1429
- const url = positional[1];
1430
- if (!url) throw new Error("Usage: impractical brand extract <url> [--wait]");
1431
- const started = await invoke("brand-extraction", {
1432
- action: "start",
1433
- url,
1434
- ...flagBool(flags, "reextract") ? { reextract: true } : {}
1435
- });
1436
- out(`Extraction started for "${started.slug}" (run ${started.job}).`);
1437
- if (!flagBool(flags, "wait")) return;
1438
- const deadline = Date.now() + 15e5;
1439
- let lastLine = "";
1440
- while (true) {
1441
- if (Date.now() >= deadline) throw new Error("Brand extraction timed out");
1442
- const statusResult = await invoke("brand-extraction", { action: "status", job: started.job });
1443
- const line = `${statusResult.status}${statusResult.pct != null ? ` ${statusResult.pct}%` : ""}${statusResult.stage ? ` \u2014 ${statusResult.stage}` : ""}`;
1444
- if (line !== lastLine) {
1445
- lastLine = line;
1446
- out(line);
1447
- }
1448
- if (statusResult.status === "done") {
1449
- out(`Brand kit saved as "${statusResult.slug ?? started.slug}". Use it with: impractical generate --brand ${statusResult.slug ?? started.slug} ...`);
1450
- return;
1451
- }
1452
- if (statusResult.status === "failed" || statusResult.error) {
1453
- throw new Error(`Brand extraction failed${statusResult.error ? `: ${statusResult.error}` : ""}`);
1454
- }
1455
- await new Promise((resolve) => setTimeout(resolve, 2e3));
1456
- }
1457
- }
1458
- if (action === "list" || !action) {
1459
- const result = await invoke("brand-kits", {
1460
- action: "list"
1461
- });
1462
- for (const kit of result.brands) out(`${kit.slug}${kit.name ? ` (${kit.name})` : ""}`);
1463
- if (!result.brands.length) out("No brand kits yet. Extract one with: impractical brand extract <url> --wait");
1464
- return;
1465
- }
1466
- if (action === "get") {
1467
- const slug = positional[1];
1468
- if (!slug) throw new Error("Usage: impractical brand get <slug>");
1469
- const result = await invoke("brand-kits", { action: "get", slug });
1470
- out(JSON.stringify(result.brand, null, 2));
1471
- return;
1472
- }
1473
- throw new Error(`Unknown brand action "${action}" (use: extract, list, get)`);
1474
- }
1475
- function validateMeteredCommand(command, positional, flags) {
1476
- if (command === "generate" && !flagString(flags, "value-prop")) {
1477
- throw new Error('Usage: impractical generate --value-prop "..." [--wait]');
1478
- }
1479
- if (command === "status" && !flagString(flags, "project") && !flagString(flags, "jobs")) {
1480
- throw new Error("Usage: impractical status --project <id> | --jobs <id,id> [--wait]");
1481
- }
1482
- if (command === "export" && !positional[0]) {
1483
- throw new Error("Usage: impractical export <draftId> [--wait] [--out video.mp4]");
1484
- }
1485
- if (command === "upload" && (!positional[0] || !flagString(flags, "project") || !flagString(flags, "draft"))) {
1486
- throw new Error("Usage: impractical upload <file> --project <projectId> --draft <draftId>");
1487
- }
1488
- if (command === "brand") {
1489
- const action = positional[0];
1490
- if (action === "extract" && !positional[1]) {
1491
- throw new Error("Usage: impractical brand extract <url> [--wait]");
1492
- }
1493
- if (action === "get" && !positional[1]) {
1494
- throw new Error("Usage: impractical brand get <slug>");
1495
- }
1496
- if (action && !["extract", "list", "get"].includes(action)) {
1497
- throw new Error(`Unknown brand action "${action}" (use: extract, list, get)`);
1498
- }
1499
- }
1500
42
  }
1501
43
 
1502
- // src/index.ts
44
+ // src/cli-runtime.ts
1503
45
  var HELP = `impractical \u2014 Impractical Motion from your terminal (and your coding agent)
1504
46
 
1505
47
  Setup
@@ -1527,68 +69,48 @@ Edit drafts
1527
69
  Assets & brands
1528
70
  upload <file> --project <id> --draft <id>
1529
71
  brand extract <url> [--wait] | brand list | brand get <slug>
72
+
73
+ Info
74
+ --version, -v Print the installed CLI version
1530
75
  `;
1531
- var OPTIONS = {
1532
- key: { type: "string" },
1533
- "supabase-url": { type: "string" },
1534
- "app-url": { type: "string" },
1535
- "value-prop": { type: "string" },
1536
- vibe: { type: "string" },
1537
- directors: { type: "string" },
1538
- "code-file": { type: "string" },
1539
- brand: { type: "string" },
1540
- asset: { type: "string", multiple: true },
1541
- project: { type: "string" },
1542
- feedback: { type: "string" },
1543
- model: { type: "string" },
1544
- slug: { type: "string" },
1545
- name: { type: "string" },
1546
- jobs: { type: "string" },
1547
- dir: { type: "string" },
1548
- out: { type: "string" },
1549
- draft: { type: "string" },
1550
- id: { type: "string" },
1551
- wait: { type: "boolean" },
1552
- agent: { type: "boolean" },
1553
- claude: { type: "boolean" },
1554
- "no-open": { type: "boolean" },
1555
- reextract: { type: "boolean" },
1556
- help: { type: "boolean" }
1557
- };
1558
- async function main() {
1559
- const { values, positionals } = parseArgs({
1560
- args: process.argv.slice(2),
1561
- options: OPTIONS,
1562
- allowPositionals: true
1563
- });
76
+ async function runCli(args, deps) {
77
+ const { values, positionals } = parseCliArgs(args);
78
+ if (values.version) {
79
+ deps.writeLine(PACKAGE_VERSION);
80
+ return;
81
+ }
1564
82
  const [command, ...rest] = positionals;
1565
83
  if (!command || values.help) {
1566
- console.log(HELP);
84
+ deps.writeLine(HELP);
1567
85
  return;
1568
86
  }
1569
87
  const flags = values;
88
+ if (command === "mcp") {
89
+ const { runStdioServer } = await deps.loadMcp();
90
+ return runStdioServer();
91
+ }
92
+ const [commands, { withMeteredCall }] = await Promise.all([
93
+ deps.loadCommands(),
94
+ deps.loadApi()
95
+ ]);
1570
96
  switch (command) {
1571
97
  case "setup":
1572
- return setup2(flags);
98
+ return commands.setup(flags);
1573
99
  case "motion":
1574
- if (rest[0] === "wizard") return setup2(flags);
100
+ if (rest[0] === "wizard") return commands.setup(flags);
1575
101
  break;
1576
- case "mcp": {
1577
- const { runStdioServer } = await import("./stdio-VWTZOK6D.js");
1578
- return runStdioServer();
1579
- }
1580
102
  case "login":
1581
- return login(flags);
103
+ return commands.login(flags);
1582
104
  case "whoami":
1583
- return whoami(flags);
105
+ return commands.whoami(flags);
1584
106
  case "keys":
1585
- return keys(rest, flags);
107
+ return commands.keys(rest, flags);
1586
108
  case "generate":
1587
- validateMeteredCommand("generate", rest, flags);
1588
- return withMeteredCall("cli_generate", () => generate(flags));
109
+ commands.validateMeteredCommand("generate", rest, flags);
110
+ return withMeteredCall("cli_generate", () => commands.generate(flags));
1589
111
  case "status":
1590
- validateMeteredCommand("status", rest, flags);
1591
- return withMeteredCall("cli_status", () => status(flags));
112
+ commands.validateMeteredCommand("status", rest, flags);
113
+ return withMeteredCall("cli_status", () => commands.status(flags));
1592
114
  case "pull":
1593
115
  case "push":
1594
116
  throw new Error(
@@ -1596,24 +118,32 @@ async function main() {
1596
118
  npx --yes @impractical-ai/motion setup --claude`
1597
119
  );
1598
120
  case "export":
1599
- validateMeteredCommand("export", rest, flags);
1600
- return withMeteredCall("cli_export", () => exportDraft(rest, flags));
121
+ commands.validateMeteredCommand("export", rest, flags);
122
+ return withMeteredCall("cli_export", () => commands.exportDraft(rest, flags));
1601
123
  case "upload":
1602
- validateMeteredCommand("upload", rest, flags);
1603
- return withMeteredCall("cli_upload", () => upload(rest, flags));
124
+ commands.validateMeteredCommand("upload", rest, flags);
125
+ return withMeteredCall("cli_upload", () => commands.upload(rest, flags));
1604
126
  case "link":
1605
- return link(rest, flags);
127
+ return commands.link(rest, flags);
1606
128
  case "brand":
1607
- validateMeteredCommand("brand", rest, flags);
1608
- return withMeteredCall("cli_brand", () => brand(rest, flags));
129
+ commands.validateMeteredCommand("brand", rest, flags);
130
+ return withMeteredCall("cli_brand", () => commands.brand(rest, flags));
1609
131
  default:
1610
- console.log(HELP);
132
+ deps.writeLine(HELP);
1611
133
  throw new Error(`Unknown command "${command}"`);
1612
134
  }
1613
- console.log(HELP);
135
+ deps.writeLine(HELP);
1614
136
  throw new Error(`Unknown command "${[command, ...rest].join(" ")}"`);
1615
137
  }
1616
- main().catch((error) => {
138
+
139
+ // src/index.ts
140
+ runCli(process.argv.slice(2), {
141
+ writeLine: (line) => console.log(line),
142
+ loadCommands: () => import("./commands-WYBUEYA6.js"),
143
+ loadApi: () => import("./api-64X3G4M2.js"),
144
+ loadMcp: () => import("./stdio-JCRFEHDW.js")
145
+ }).catch(async (error) => {
146
+ const { ApiError } = await import("./api-64X3G4M2.js");
1617
147
  if (error instanceof ApiError) {
1618
148
  console.error(`Error (${error.status} from ${error.fn}): ${error.message}`);
1619
149
  if (error.status === 429) console.error("Weekly MCP allowance reached. Upgrade or wait for Monday UTC.");