@impractical-ai/motion 0.1.16 → 0.1.19

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